diff --git a/src/app/remotes/vertical-main-menu/vertical-main-manu.component.scss b/src/app/remotes/vertical-main-menu/vertical-main-manu.component.scss index 5a4d4a5c..d9abf586 100644 --- a/src/app/remotes/vertical-main-menu/vertical-main-manu.component.scss +++ b/src/app/remotes/vertical-main-menu/vertical-main-manu.component.scss @@ -1 +1,42 @@ @import './../../shared/sidebar-panelmenu.scss'; + +:host ::ng-deep { + .p-panelmenu { + .p-panelmenu-header { + &.ocx-vertical-menu-active-item { + &:not(:focus-visible):not(:hover) { + > .p-panelmenu-header-content { + .p-panelmenu-header-action { + &:not(:hover):not(:focus) { + background-color: var(--menu-active-item-bg-color); + color: var(--menu-active-item-text-color); + } + } + } + } + } + } + .p-panelmenu-content { + .p-menuitem { + &.ocx-vertical-menu-active-item { + &:not(.p-focus) { + > .p-menuitem-content { + &:not(:hover) { + .p-menuitem-link { + background-color: var(--menu-active-item-bg-color); + color: var(--menu-active-item-text-color); + .p-menuitem-icon { + color: var(--menu-active-item-text-color); + } + .p-menuitem-text { + color: var(--menu-active-item-text-color); + } + } + } + } + } + } + } + } + } +} diff --git a/src/app/remotes/vertical-main-menu/vertical-main-menu.component.html b/src/app/remotes/vertical-main-menu/vertical-main-menu.component.html index 3a05d3be..27789c60 100644 --- a/src/app/remotes/vertical-main-menu/vertical-main-menu.component.html +++ b/src/app/remotes/vertical-main-menu/vertical-main-menu.component.html @@ -1 +1 @@ - + diff --git a/src/app/remotes/vertical-main-menu/vertical-main-menu.component.spec.ts b/src/app/remotes/vertical-main-menu/vertical-main-menu.component.spec.ts index bb422da4..2eb239de 100644 --- a/src/app/remotes/vertical-main-menu/vertical-main-menu.component.spec.ts +++ b/src/app/remotes/vertical-main-menu/vertical-main-menu.component.spec.ts @@ -138,6 +138,14 @@ describe('OneCXVerticalMainMenuComponent', () => { ) const { fixture, component } = setUp() + spyOn(component.eventsTopic$, 'asObservable').and.returnValue( + of({ + type: 'navigated', + payload: { + url: 'page-url' + } + }) + ) await component.ngOnInit() const menu = await TestbedHarnessEnvironment.harnessForFixture(fixture, PPanelMenuHarness) @@ -183,6 +191,14 @@ describe('OneCXVerticalMainMenuComponent', () => { ) const { fixture, component } = setUp() + spyOn(component.eventsTopic$, 'asObservable').and.returnValue( + of({ + type: 'navigated', + payload: { + url: 'page-url' + } + }) + ) await component.ngOnInit() const menu = await TestbedHarnessEnvironment.harnessForFixture(fixture, PPanelMenuHarness) @@ -224,6 +240,14 @@ describe('OneCXVerticalMainMenuComponent', () => { ) const { fixture, component } = setUp() + spyOn(component.eventsTopic$, 'asObservable').and.returnValue( + of({ + type: 'navigated', + payload: { + url: 'page-url' + } + }) + ) await component.ngOnInit() const menu = await TestbedHarnessEnvironment.harnessForFixture(fixture, PPanelMenuHarness) @@ -265,6 +289,14 @@ describe('OneCXVerticalMainMenuComponent', () => { const router = TestBed.inject(Router) const { fixture, component } = setUp() + spyOn(component.eventsTopic$, 'asObservable').and.returnValue( + of({ + type: 'navigated', + payload: { + url: 'page-url' + } + }) + ) await component.ngOnInit() const menu = await TestbedHarnessEnvironment.harnessForFixture(fixture, PPanelMenuHarness) @@ -305,6 +337,14 @@ describe('OneCXVerticalMainMenuComponent', () => { ) const { fixture, component } = setUp() + spyOn(component.eventsTopic$, 'asObservable').and.returnValue( + of({ + type: 'navigated', + payload: { + url: 'page-url' + } + }) + ) await component.ngOnInit() const menu = await TestbedHarnessEnvironment.harnessForFixture(fixture, PPanelMenuHarness) @@ -341,7 +381,7 @@ describe('OneCXVerticalMainMenuComponent', () => { { key: 'CORE_AH_MGMT', name: 'Announcement & Help', - url: '', + url: 'page-url', position: 1, external: false, i18n: {}, @@ -373,6 +413,14 @@ describe('OneCXVerticalMainMenuComponent', () => { ) const { fixture, component } = setUp() + spyOn(component.eventsTopic$, 'asObservable').and.returnValue( + of({ + type: 'navigated', + payload: { + url: 'page-url' + } + }) + ) await component.ngOnInit() const menu = await TestbedHarnessEnvironment.harnessForFixture(fixture, PPanelMenuHarness) @@ -388,6 +436,208 @@ describe('OneCXVerticalMainMenuComponent', () => { expect((await secondItemChildren[1].getChildren()).length).toBe(0) }) + describe('on router changes', () => { + const baseItems = [ + { + key: 'PORTAL_MAIN_MENU', + name: 'Main Menu', + children: [ + { + key: 'CORE_WELCOME', + name: 'Welcome Page', + url: '/admin/welcome', + position: 0, + external: false, + i18n: {}, + children: [] + }, + { + key: 'CORE_AH_MGMT', + name: 'Announcement & Help', + url: 'page-url', + position: 1, + external: false, + i18n: {}, + children: [ + { + key: 'CORE_AH_MGMT_A', + name: 'Announcements', + url: '/admin/announcement', + position: 1, + external: false, + i18n: {}, + children: [] + }, + { + key: 'CORE_AH_MGMT_HI', + name: 'Help Items', + url: '/admin/help', + position: 2, + external: false, + i18n: {}, + children: [] + } + ] + } + ] + } + ] + + it('should expand active item parents', async () => { + const appStateService = TestBed.inject(AppStateService) + spyOn(appStateService.currentWorkspace$, 'asObservable').and.returnValue( + of({ + workspaceName: 'test-workspace' + }) as any + ) + spyOn(appStateService.currentMfe$, 'asObservable').and.returnValue(of({} as any)) + menuItemApiSpy.getMenuItems.and.returnValue( + of({ + workspaceName: 'test-workspace', + menu: baseItems + } as any) + ) + + const { fixture, component } = setUp() + spyOn(component.eventsTopic$, 'asObservable').and.returnValue( + of({ + type: 'navigated', + payload: { + url: '/admin/help' + } + }) + ) + await component.ngOnInit() + + const menu = await TestbedHarnessEnvironment.harnessForFixture(fixture, PPanelMenuHarness) + const panels = await menu.getAllPanels() + expect(panels.length).toEqual(2) + + expect((await panels[0].getChildren()).length).toBe(0) + const secondItemChildren = await panels[1].getChildren() + expect(secondItemChildren.length).toBe(2) + expect(await secondItemChildren[0].getText()).toEqual('Announcements') + expect(await (await secondItemChildren[0].host()).hasClass(component.activeItemClass)).toBeFalse() + expect(await secondItemChildren[1].getText()).toEqual('Help Items') + expect(await (await secondItemChildren[1].host()).hasClass(component.activeItemClass)).toBeTrue() + + const menuItems = component.menuItems$.getValue() + expect(menuItems?.items.length).toBe(2) + expect(menuItems?.items[0].expanded).toBeFalsy() + expect(menuItems?.items[1].expanded).toBeTrue() + }) + + it('should update items if workspace did not change', async () => { + const appStateService = TestBed.inject(AppStateService) + spyOn(appStateService.currentWorkspace$, 'asObservable').and.returnValue( + of({ + workspaceName: 'test-workspace' + }) as any + ) + spyOn(appStateService.currentMfe$, 'asObservable').and.returnValue(of({} as any)) + menuItemApiSpy.getMenuItems.and.returnValue( + of({ + workspaceName: 'test-workspace', + menu: [ + { + key: 'my-item', + name: 'item-name-1', + url: '/admin/help', + position: 2, + external: false, + i18n: {}, + children: [] + } + ] + } as any) + ) + + const { component } = setUp() + spyOn(component.eventsTopic$, 'asObservable').and.returnValue( + of({ + type: 'navigated', + payload: { + url: '/admin/help' + } + }) + ) + component.menuItems$.next({ + workspaceName: 'test-workspace', + items: [ + { + id: 'my-item', + items: undefined, + label: 'item-name-2', + routerLink: '/admin/help' + } + ] + }) + await component.ngOnInit() + + const menuItems = component.menuItems$.getValue() + expect(menuItems?.items.length).toBe(1) + expect(menuItems?.items[0].label).toBe('item-name-2') + }) + + it('should overwrite items if workspace has changed', async () => { + const appStateService = TestBed.inject(AppStateService) + spyOn(appStateService.currentWorkspace$, 'asObservable').and.returnValue( + of({ + workspaceName: 'other-workspace' + }) as any + ) + spyOn(appStateService.currentMfe$, 'asObservable').and.returnValue(of({} as any)) + menuItemApiSpy.getMenuItems.and.returnValue( + of({ + workspaceName: 'other-workspace', + menu: [ + { + key: 'PORTAL_MAIN_MENU', + name: 'Main Menu', + children: [ + { + key: 'my-item', + name: 'item-name-1', + url: '/admin/help', + position: 2, + external: false, + i18n: {}, + children: [] + } + ] + } + ] + } as any) + ) + + const { component } = setUp() + spyOn(component.eventsTopic$, 'asObservable').and.returnValue( + of({ + type: 'navigated', + payload: { + url: '/admin/help' + } + }) + ) + component.menuItems$.next({ + workspaceName: 'test-workspace', + items: [ + { + id: 'my-item', + items: undefined, + label: 'item-name-2', + routerLink: '/admin/help' + } + ] + }) + await component.ngOnInit() + + const menuItems = component.menuItems$.getValue() + expect(menuItems?.items.length).toBe(1) + expect(menuItems?.items[0].label).toBe('item-name-1') + }) + }) + it('should return 0 panels when unable to load them', async () => { const appStateService = TestBed.inject(AppStateService) spyOn(appStateService.currentWorkspace$, 'asObservable').and.returnValue( diff --git a/src/app/remotes/vertical-main-menu/vertical-main-menu.component.ts b/src/app/remotes/vertical-main-menu/vertical-main-menu.component.ts index fd6b18c6..5369a8ca 100644 --- a/src/app/remotes/vertical-main-menu/vertical-main-menu.component.ts +++ b/src/app/remotes/vertical-main-menu/vertical-main-menu.component.ts @@ -1,6 +1,6 @@ import { CommonModule, Location } from '@angular/common' import { HttpClient } from '@angular/common/http' -import { Component, Inject, Input, OnInit } from '@angular/core' +import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core' import { RouterModule } from '@angular/router' import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy' import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core' @@ -12,6 +12,7 @@ import { ocxRemoteWebcomponent, provideTranslateServiceForRoot } from '@onecx/angular-remote-components' +import { EventsTopic, NavigatedEventPayload } from '@onecx/integration-interface' import { AppStateService, PortalCoreModule, @@ -20,12 +21,30 @@ import { } from '@onecx/portal-integration-angular' import { MenuItem } from 'primeng/api' import { PanelMenuModule } from 'primeng/panelmenu' -import { Observable, ReplaySubject, catchError, map, mergeMap, of, retry, shareReplay, withLatestFrom } from 'rxjs' +import { + BehaviorSubject, + ReplaySubject, + catchError, + combineLatest, + distinctUntilChanged, + filter, + map, + mergeMap, + of, + retry, + shareReplay, + withLatestFrom +} from 'rxjs' import { Configuration, MenuItemAPIService } from 'src/app/shared/generated' import { MenuItemService } from 'src/app/shared/services/menu-item.service' import { SharedModule } from 'src/app/shared/shared.module' import { environment } from 'src/environments/environment' +export interface WorkspaceMenuItems { + workspaceName: string + items: MenuItem[] +} + @Component({ selector: 'app-vertical-main-menu', templateUrl: './vertical-main-menu.component.html', @@ -56,8 +75,14 @@ import { environment } from 'src/environments/environment' ] }) @UntilDestroy() -export class OneCXVerticalMainMenuComponent implements ocxRemoteComponent, ocxRemoteWebcomponent, OnInit { - menuItems$: Observable | undefined +export class OneCXVerticalMainMenuComponent implements ocxRemoteComponent, ocxRemoteWebcomponent, OnInit, OnDestroy { + eventsTopic$ = new EventsTopic() + + menuItems$: BehaviorSubject = new BehaviorSubject( + undefined + ) + + activeItemClass = 'ocx-vertical-menu-active-item' constructor( @Inject(BASE_URL) private readonly baseUrl: ReplaySubject, @@ -69,6 +94,9 @@ export class OneCXVerticalMainMenuComponent implements ocxRemoteComponent, ocxRe ) { this.userService.lang$.subscribe((lang) => this.translateService.use(lang)) } + ngOnDestroy(): void { + this.eventsTopic$.destroy() + } @Input() set ocxRemoteComponentConfig(config: RemoteComponentConfig) { this.ocxInitRemoteComponent(config) @@ -82,11 +110,36 @@ export class OneCXVerticalMainMenuComponent implements ocxRemoteComponent, ocxRe } ngOnInit(): void { - this.getMenuItems() + combineLatest([ + this.eventsTopic$.asObservable().pipe( + filter((e) => e.type === 'navigated'), + map((e) => (e.payload as NavigatedEventPayload).url), + filter((url): url is string => !!url), + distinctUntilChanged() + ), + this.getMenuItems() + ]) + .pipe( + map(([url, workspaceItems]) => { + const currentItems = this.menuItems$.getValue() + if (!currentItems || currentItems.workspaceName !== workspaceItems.workspaceName) { + return { + workspaceName: workspaceItems.workspaceName, + items: this.changeActiveItem(url, workspaceItems.items) + } + } else { + return { + workspaceName: currentItems.workspaceName, + items: this.changeActiveItem(url, currentItems.items) + } + } + }) + ) + .subscribe(this.menuItems$) } - getMenuItems() { - this.menuItems$ = this.appStateService.currentWorkspace$.pipe( + private getMenuItems() { + return this.appStateService.currentWorkspace$.pipe( mergeMap((currentWorkspace) => this.menuItemApiService .getMenuItems({ @@ -96,6 +149,7 @@ export class OneCXVerticalMainMenuComponent implements ocxRemoteComponent, ocxRe } }) .pipe( + map((response) => ({ data: response, workspaceName: currentWorkspace.workspaceName })), retry({ delay: 500, count: 3 }), catchError(() => { console.error('Unable to load menu items for vertical main menu.') @@ -103,12 +157,39 @@ export class OneCXVerticalMainMenuComponent implements ocxRemoteComponent, ocxRe }) ) ), - withLatestFrom(this.userService.lang$, this.appStateService.currentMfe$.asObservable()), - map(([data, userLang, mfeInfo]) => - this.menuItemService.constructMenuItems(data?.menu?.[0]?.children, userLang, mfeInfo.baseHref) + withLatestFrom(this.userService.lang$), + map( + ([workspaceItems, userLang]): WorkspaceMenuItems => ({ + workspaceName: workspaceItems?.workspaceName ?? '', + items: this.menuItemService.constructMenuItems(workspaceItems?.data?.menu?.[0]?.children, userLang) + }) ), shareReplay(), untilDestroyed(this) ) } + + private changeActiveItem(url: string, menuItems: MenuItem[]): MenuItem[] { + const bestMatch = this.menuItemService.findActiveItemBestMatch(menuItems, url) + if (bestMatch) { + for (const item of bestMatch.parents) { + item.expanded = true + } + } + const items = menuItems.map((i) => this.updateItemsByActiveItem(i, bestMatch?.item)) + return items + } + + private updateItemsByActiveItem(item: MenuItem, activeItem: MenuItem | undefined): MenuItem { + return { + styleClass: item.id === activeItem?.id ? this.activeItemClass : '', + items: item.items?.map((i) => this.updateItemsByActiveItem(i, activeItem)), + label: item.label, + id: item.id, + icon: item.icon, + routerLink: item.routerLink, + url: item.url, + expanded: item.expanded + } + } } diff --git a/src/app/shared/services/menu-item.service.spec.ts b/src/app/shared/services/menu-item.service.spec.ts index 77321f9c..c2a1ff4c 100644 --- a/src/app/shared/services/menu-item.service.spec.ts +++ b/src/app/shared/services/menu-item.service.spec.ts @@ -89,8 +89,7 @@ describe('MenuItemService', () => { icon: 'pi pi-star', routerLink: '/item1', items: undefined, - url: undefined, - routerLinkActiveOptions: { exact: true } + url: undefined }, { id: '2', @@ -98,8 +97,7 @@ describe('MenuItemService', () => { icon: 'pi pi-check', routerLink: undefined, items: undefined, - url: 'http://external.com', - routerLinkActiveOptions: { exact: true } + url: 'http://external.com' } ] const result = service.constructMenuItems(input, 'en') @@ -138,8 +136,7 @@ describe('MenuItemService', () => { icon: 'pi pi-star', routerLink: '/item1?param=[[DONTREPLACEME]]', items: undefined, - url: undefined, - routerLinkActiveOptions: { exact: true } + url: undefined }, { id: '2', @@ -147,8 +144,7 @@ describe('MenuItemService', () => { icon: 'pi pi-check', routerLink: undefined, items: undefined, - url: 'http://external.com?id=2', - routerLinkActiveOptions: { exact: true } + url: 'http://external.com?id=2' } ] const result = service.constructMenuItems(input, 'en') @@ -189,8 +185,7 @@ describe('MenuItemService', () => { icon: 'pi pi-star', routerLink: '/item1?param=[[DONTREPLACEME]]', items: undefined, - url: undefined, - routerLinkActiveOptions: { exact: true } + url: undefined }, { id: '2', @@ -198,8 +193,7 @@ describe('MenuItemService', () => { icon: 'pi pi-check', routerLink: undefined, items: undefined, - url: 'http://external.com?id=1', - routerLinkActiveOptions: { exact: true } + url: 'http://external.com?id=1' } ] sessionStorage.clear() @@ -241,8 +235,7 @@ describe('MenuItemService', () => { icon: 'pi pi-star', routerLink: '/item1?param=[[DONTREPLACEME]]', items: undefined, - url: undefined, - routerLinkActiveOptions: { exact: true } + url: undefined }, { id: '2', @@ -250,8 +243,7 @@ describe('MenuItemService', () => { icon: 'pi pi-check', routerLink: undefined, items: undefined, - url: 'http://external.com?id=', - routerLinkActiveOptions: { exact: true } + url: 'http://external.com?id=' } ] sessionStorage.clear() @@ -294,8 +286,7 @@ describe('MenuItemService', () => { icon: 'pi pi-star', routerLink: '/item1?param=[[DONTREPLACEME]]', items: undefined, - url: undefined, - routerLinkActiveOptions: { exact: true } + url: undefined }, { id: '2', @@ -303,8 +294,7 @@ describe('MenuItemService', () => { icon: 'pi pi-check', routerLink: undefined, items: undefined, - url: 'http://external.com?id=2&mykey=my-sessionstorage-key', - routerLinkActiveOptions: { exact: true } + url: 'http://external.com?id=2&mykey=my-sessionstorage-key' } ] const result = service.constructMenuItems(input, 'en') @@ -334,12 +324,48 @@ describe('MenuItemService', () => { external: false, url: '/item1', badge: 'star', - children: [], - i18n: { en: 'Item 1 EN' } + i18n: { en: 'Item 1 EN' }, + children: [ + { + key: '5', + name: 'Item 5', + position: 3, + disabled: false, + external: false, + url: '/item5', + badge: 'check', + children: [], + i18n: { en: 'Item 5 EN' } + }, + { + key: '3', + name: 'Item 3', + disabled: false, + external: true, + badge: 'check', + children: [], + i18n: { en: 'Item 3 EN' } + }, + { + key: '4', + name: 'Item 4', + position: 2, + disabled: false, + external: false, + url: '/item4', + badge: 'check', + children: [], + i18n: { en: 'Item 4 EN' } + } + ] } ] const result = service.constructMenuItems(input, 'en') expect(result[0].id).toBe('1') + expect(result[0].items?.length).toBe(3) + expect(result[0].items?.at(0)?.id).toBe('3') + expect(result[0].items?.at(1)?.id).toBe('4') + expect(result[0].items?.at(2)?.id).toBe('5') expect(result[1].id).toBe('2') }) @@ -483,103 +509,16 @@ describe('MenuItemService', () => { icon: 'pi pi-child', routerLink: '/child', items: undefined, - url: undefined, - routerLinkActiveOptions: { exact: true } + url: undefined } ], - url: undefined, - routerLinkActiveOptions: { exact: true } + url: undefined } ] const result = service.constructMenuItems(input, 'en') expect(result).toEqual(expected) }) - it('should expand parents of current mfe item', () => { - const input: UserWorkspaceMenuItem[] = [ - { - key: '1', - name: 'Parent Item', - position: 1, - disabled: false, - external: false, - children: [ - { - key: '1.1', - name: 'Second parent Item', - position: 1, - disabled: false, - external: false, - children: [ - { - key: '1.1.1', - name: 'Child Item', - position: 1, - disabled: false, - external: false, - url: '/admin/mfe', - children: [] - }, - { - key: '1.1.2', - name: 'Second Child Item', - position: 2, - disabled: false, - external: false, - url: 'admin/otherMfe/', - children: [] - } - ] - } - ] - } - ] - const expected: MenuItem[] = [ - { - id: '1', - label: '', - expanded: true, - icon: undefined, - routerLink: undefined, - url: undefined, - routerLinkActiveOptions: { exact: true }, - items: [ - { - id: '1.1', - label: '', - expanded: true, - icon: undefined, - routerLink: undefined, - url: undefined, - routerLinkActiveOptions: { exact: true }, - items: [ - { - id: '1.1.1', - label: '', - items: undefined, - routerLink: '/admin/mfe', - url: undefined, - icon: undefined, - routerLinkActiveOptions: { exact: true } - }, - { - id: '1.1.2', - label: '', - items: undefined, - routerLink: 'admin/otherMfe/', - url: undefined, - icon: undefined, - routerLinkActiveOptions: { exact: true } - } - ] - } - ] - } - ] - const result = service.constructMenuItems(input, 'en', '/admin/mfe') - expect(result).toEqual(expected) - }) - it('should handle different languages', () => { const input: UserWorkspaceMenuItem[] = [ { @@ -599,4 +538,47 @@ describe('MenuItemService', () => { expect(resultEn[0].label).toBe('Item 1 EN') expect(resultFr[0].label).toBe('Item 1 FR') }) + + it('should select closest item as best match', () => { + const items: MenuItem[] = [ + { + label: 'Parent1', + items: [ + { + label: 'Workspace', + routerLink: 'admin/workspace' + }, + { + label: 'User search', + routerLink: 'admin/user/search/' + }, + { + label: 'Help', + routerLink: 'admin/help' + } + ] + }, + { + label: 'Parent2', + items: [ + { + label: 'Tenant', + routerLink: 'admin/tenant' + }, + { + label: 'MyPage', + routerLink: 'admin/help/my-page' + } + ] + } + ] + + const result = service.findActiveItemBestMatch(items, '/admin/help/my-page') + + expect(result).toBeDefined() + expect(result?.item.label).toBe('MyPage') + expect(result?.matchedSegments).toEqual(3) + expect(result?.parents.length).toBe(1) + expect(result?.parents[0].label).toBe('Parent2') + }) }) diff --git a/src/app/shared/services/menu-item.service.ts b/src/app/shared/services/menu-item.service.ts index 90bacad2..96d6b3e2 100644 --- a/src/app/shared/services/menu-item.service.ts +++ b/src/app/shared/services/menu-item.service.ts @@ -4,23 +4,61 @@ import { MenuItem } from 'primeng/api' @Injectable({ providedIn: 'root' }) export class MenuItemService { - public constructMenuItems( - userWorkspaceMenuItem: UserWorkspaceMenuItem[] | undefined, - userLang: string, - currentMfePath?: string - ): MenuItem[] { + public constructMenuItems(userWorkspaceMenuItem: UserWorkspaceMenuItem[] | undefined, userLang: string): MenuItem[] { const workspaceMenuItems = userWorkspaceMenuItem?.filter((i) => i) // exclude undefined if (workspaceMenuItems) { workspaceMenuItems.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) const menuItems = workspaceMenuItems.filter((i) => !i.disabled).map((item) => this.mapMenuItem(item, userLang)) - const mfePath = this.stripPath(currentMfePath) - mfePath && this.expandCurrentMfeMenuItems(menuItems, mfePath) return menuItems } else { return [] } } + findActiveItemBestMatch( + items: MenuItem[], + path: string + ): { item: MenuItem; parents: MenuItem[]; matchedSegments: number } | undefined { + const pathToMatch = this.stripPath(path) + let bestMatch: { item: MenuItem; parents: MenuItem[]; matchedSegments: number } | undefined = undefined + + for (const item of items) { + const segments = this.getMatchedSegments(item, pathToMatch) + if (segments > (bestMatch?.matchedSegments || 0)) { + bestMatch = { + item: item, + parents: [], + matchedSegments: segments + } + } + + if (item.items) { + const bestChildMatch = this.findActiveItemBestMatch(item.items, pathToMatch) + if (bestChildMatch && bestChildMatch.matchedSegments > (bestMatch?.matchedSegments || 0)) { + bestMatch = { + item: bestChildMatch.item, + parents: [...bestChildMatch.parents, item], + matchedSegments: bestChildMatch.matchedSegments + } + } + } + } + + return bestMatch + } + + private getMatchedSegments(item: MenuItem, strippedPath: string): number { + const itemStrippedPath = item.routerLink ? this.stripPath(item.routerLink) : undefined + if (itemStrippedPath && itemStrippedPath === strippedPath) { + return this.countSegments(this.stripPath(strippedPath)) + } else if (itemStrippedPath && strippedPath.includes(itemStrippedPath)) { + const matchedSegments = this.countSegments(itemStrippedPath) + return matchedSegments + } + + return 0 + } + /** Item is never undefined when filtered out in constructMenuItems() */ private mapMenuItem(item: UserWorkspaceMenuItem, userLang: string): MenuItem { const isLocal: boolean = !item.external @@ -39,8 +77,7 @@ export class MenuItemService { label, icon: item.badge ? 'pi pi-' + item.badge : undefined, routerLink: isLocal ? this.stripBaseHref(item.url) : undefined, - url: isLocal ? undefined : this.replaceUrlVariables(item.url), - routerLinkActiveOptions: { exact: true } + url: isLocal ? undefined : this.replaceUrlVariables(item.url) } } @@ -50,20 +87,12 @@ export class MenuItemService { return url?.replace(baseUrl, '') } - private stripPath(path: string | undefined): string | undefined { - return path?.slice(path.startsWith('/') ? 1 : 0, path[-1] === '/' ? -1 : path.length) + private stripPath(path: string): string { + return path.slice(path.at(0) === '/' ? 1 : 0, path.at(-1) === '/' ? -1 : path.length) } - private expandCurrentMfeMenuItems(items: MenuItem[], currentMfePath: string): boolean { - for (const item of items) { - if (this.stripPath(item.routerLink) === currentMfePath) return true - else if (item.items && this.expandCurrentMfeMenuItems(item.items, currentMfePath)) { - item.expanded = true - return true - } - } - - return false + private countSegments(path: string): number { + return path.split('/').length } private replaceUrlVariables(url: string | undefined): string | undefined { diff --git a/src/app/shared/sidebar-panelmenu.scss b/src/app/shared/sidebar-panelmenu.scss index dfe20bf3..72461dca 100644 --- a/src/app/shared/sidebar-panelmenu.scss +++ b/src/app/shared/sidebar-panelmenu.scss @@ -12,11 +12,6 @@ background: var(--menu-item-hover-bg-color); color: var(--menu-item-text-color); } - - &.p-menuitem-link-active:not(:hover) { - background-color: var(--menu-active-item-bg-color); - color: var(--menu-active-item-text-color); - } } } @@ -24,9 +19,7 @@ &:focus-visible { .p-panelmenu-header-content { .p-panelmenu-header-action { - &:not(.p-menuitem-link-active) { - background: var(--menu-item-hover-bg-color); - } + background: var(--menu-item-hover-bg-color); } } } @@ -34,9 +27,7 @@ &:not(.p-highlight):not(.p-disabled) { .p-panelmenu-header-action:focus { - &:not(.p-menuitem-link-active) { - background: var(--menu-item-hover-bg-color); - } + background: var(--menu-item-hover-bg-color); } } @@ -44,11 +35,6 @@ .p-panelmenu-header-content { color: var(--menu-item-text-color); background: var(--menu-item-hover-bg-color); - - &.p-menuitem-link-active { - color: var(--menu-item-text-color); - background: var(--menu-item-hover-bg-color); - } } } @@ -82,22 +68,11 @@ .p-submenu-icon { color: var(--menu-item-text-color); } - - &.p-menuitem-link-active { - background: var(--menu-active-item-bg-color); - color: var(--menu-active-item-text-color); - - .p-menuitem-text, - .p-menuitem-icon, - .p-submenu-icon { - color: var(--menu-active-item-text-color); - } - } } } &.p-highlight { - > .p-menuitem-content:not(:has(.p-menuitem-link-active)) { + > .p-menuitem-content { color: var(--menu-item-text-color); background: var(--menu-item-hover-bg-color); @@ -110,7 +85,7 @@ } } - &.p-focus:not(:has(.p-menuitem-link-active)) { + &.p-focus { > .p-menuitem-content { background: var(--menu-item-hover-bg-color); } @@ -118,7 +93,7 @@ } &:not(.p-highlight):not(.p-disabled) { - &.p-focus:not(:has(.p-menuitem-link-active)) { + &.p-focus { > .p-menuitem-content { color: var(--menu-item-text-color); background: var(--menu-item-hover-bg-color); @@ -141,17 +116,6 @@ .p-submenu-icon { color: var(--menu-item-text-color); } - - &.p-menuitem-link-active { - background: var(--menu-item-hover-bg-color); - color: var(--menu-item-text-color); - - .p-menuitem-text, - .p-menuitem-icon, - .p-submenu-icon { - color: var(--menu-item-text-color); - } - } } } }