diff --git a/gravitee-apim-portal-webui-next/src/app/app.component.html b/gravitee-apim-portal-webui-next/src/app/app.component.html index 4673da00aeb..cb9a74a7b8f 100644 --- a/gravitee-apim-portal-webui-next/src/app/app.component.html +++ b/gravitee-apim-portal-webui-next/src/app/app.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - +
diff --git a/gravitee-apim-portal-webui-next/src/app/app.component.spec.ts b/gravitee-apim-portal-webui-next/src/app/app.component.spec.ts index 2f34f53bc7c..0a42e6135bc 100644 --- a/gravitee-apim-portal-webui-next/src/app/app.component.spec.ts +++ b/gravitee-apim-portal-webui-next/src/app/app.component.spec.ts @@ -16,12 +16,13 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { Injectable } from '@angular/core'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatButtonHarness } from '@angular/material/button/testing'; import { AppComponent } from './app.component'; -import { PortalMenuLinksService } from '../services/portal-menu-links.service'; +import { PortalNavigationItem } from '../entities/portal-navigation/portal-navigation'; +import { PortalNavigationItemsService } from '../services/portal-navigation-items.service'; import { AppTestingModule } from '../testing/app-testing.module'; describe('AppComponent', () => { @@ -45,37 +46,36 @@ describe('AppComponent', () => { }); describe('custom links', () => { - @Injectable() - class PortalMenuLinksServiceStub { - links = () => [ + beforeEach(async () => { + const mockItems: PortalNavigationItem[] = [ { - id: 'link-id-1', - type: 'external', - name: 'link-name-1', - target: 'link-target-1', - order: 1, + id: 'l1', + organizationId: 'org1', + environmentId: 'env1', + title: 'link-name-1', + type: 'LINK', + area: 'TOP_NAVBAR', + order: 0, + url: '/link1', }, { - id: 'link-id-2', - type: 'external', - name: 'link-name-2', - target: 'link-target-2', - order: 2, + id: 'l2', + organizationId: 'org1', + environmentId: 'env1', + title: 'link-name-2', + type: 'LINK', + area: 'TOP_NAVBAR', + order: 1, + url: '/link2', }, ]; - } - beforeEach(async () => { await TestBed.configureTestingModule({ imports: [AppComponent, AppTestingModule], - providers: [ - { - provide: PortalMenuLinksService, - useClass: PortalMenuLinksServiceStub, - }, - ], + providers: [provideHttpClientTesting(), { provide: PortalNavigationItemsService, useValue: { topNavbar: signal(mockItems) } }], }).compileComponents(); fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); harnessLoader = TestbedHarnessEnvironment.loader(fixture); }); it('should show custom links', async () => { diff --git a/gravitee-apim-portal-webui-next/src/app/app.component.ts b/gravitee-apim-portal-webui-next/src/app/app.component.ts index 20386c4ad1f..df5498d8146 100644 --- a/gravitee-apim-portal-webui-next/src/app/app.component.ts +++ b/gravitee-apim-portal-webui-next/src/app/app.component.ts @@ -22,7 +22,7 @@ import { FooterComponent } from '../components/footer/footer.component'; import { NavBarComponent } from '../components/nav-bar/nav-bar.component'; import { ConfigService } from '../services/config.service'; import { CurrentUserService } from '../services/current-user.service'; -import { PortalMenuLinksService } from '../services/portal-menu-links.service'; +import { PortalNavigationItemsService } from '../services/portal-navigation-items.service'; import { ThemeService } from '../services/theme.service'; @Component({ @@ -35,7 +35,7 @@ export class AppComponent { currentUser = inject(CurrentUserService).user; logo = inject(ThemeService).logo; favicon = inject(ThemeService).favicon; - customLinks = inject(PortalMenuLinksService).links; + topBarNavigationItems = inject(PortalNavigationItemsService).topNavbar; private siteTitle: string; constructor( diff --git a/gravitee-apim-portal-webui-next/src/app/app.config.ts b/gravitee-apim-portal-webui-next/src/app/app.config.ts index 980cd6b80de..d6fa0724af7 100644 --- a/gravitee-apim-portal-webui-next/src/app/app.config.ts +++ b/gravitee-apim-portal-webui-next/src/app/app.config.ts @@ -30,7 +30,7 @@ import { httpRequestInterceptor } from '../interceptors/http-request.interceptor import { AuthService } from '../services/auth.service'; import { ConfigService } from '../services/config.service'; import { CurrentUserService } from '../services/current-user.service'; -import { PortalMenuLinksService } from '../services/portal-menu-links.service'; +import { PortalNavigationItemsService } from '../services/portal-navigation-items.service'; import { ThemeService } from '../services/theme.service'; function initApp( @@ -38,7 +38,7 @@ function initApp( configService: ConfigService, themeService: ThemeService, currentUserService: CurrentUserService, - portalMenuLinksService: PortalMenuLinksService, + portalNavigationItemsService: PortalNavigationItemsService, router: Router, ): () => Observable { return () => @@ -47,7 +47,7 @@ function initApp( combineLatest([ themeService.loadTheme(), configService.loadConfiguration(), - portalMenuLinksService.loadCustomLinks(), + portalNavigationItemsService.loadTopNavBarItems(), authService.load().pipe(switchMap(_ => currentUserService.loadUser())), ]), ), @@ -70,7 +70,7 @@ export const appConfig: ApplicationConfig = { inject(ConfigService), inject(ThemeService), inject(CurrentUserService), - inject(PortalMenuLinksService), + inject(PortalNavigationItemsService), inject(Router), ); return initializerFn(); diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.spec.ts b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.spec.ts index 67002e6f122..0d5a5df0e8f 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.spec.ts +++ b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.spec.ts @@ -123,7 +123,7 @@ describe('LogInComponent', () => { await submitButton.click(); httpTestingController.expectOne(`${TESTING_BASE_URL}/auth/login`).flush({}); httpTestingController.expectOne(`${TESTING_BASE_URL}/user`).flush({}); - httpTestingController.expectOne(`${TESTING_BASE_URL}/portal-menu-links`).flush({}); + httpTestingController.expectOne(`${TESTING_BASE_URL}/portal-navigation-items?area=TOP_NAVBAR&loadChildren=false`).flush({}); }); it('should not display log-in form', async () => { diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.ts b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.ts index d6dc5310727..0fca7d211f0 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.ts +++ b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.ts @@ -33,7 +33,7 @@ import { AuthService } from '../../services/auth.service'; import { ConfigService } from '../../services/config.service'; import { CurrentUserService } from '../../services/current-user.service'; import { IdentityProviderService } from '../../services/identity-provider.service'; -import { PortalMenuLinksService } from '../../services/portal-menu-links.service'; +import { PortalNavigationItemsService } from '../../services/portal-navigation-items.service'; @Component({ selector: 'app-log-in', @@ -68,7 +68,7 @@ export class LogInComponent { constructor( private readonly authService: AuthService, private readonly currentUserService: CurrentUserService, - private readonly portalMenuLinksService: PortalMenuLinksService, + private readonly portalNavigationItemsService: PortalNavigationItemsService, private readonly router: Router, private readonly destroyRef: DestroyRef, ) {} @@ -78,7 +78,7 @@ export class LogInComponent { .login(this.logInForm.value.username, this.logInForm.value.password) .pipe( switchMap(_ => this.currentUserService.loadUser()), - switchMap(_ => this.portalMenuLinksService.loadCustomLinks()), + switchMap(_ => this.portalNavigationItemsService.loadTopNavBarItems()), tap(_ => this.router.navigate([this.redirectUrl()])), takeUntilDestroyed(this.destroyRef), ) diff --git a/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.spec.ts b/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.spec.ts index 52a0eba52cf..6c378162f88 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.spec.ts +++ b/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.spec.ts @@ -49,7 +49,7 @@ describe('LogOutComponent', () => { fixture.detectChanges(); httpTestingController.expectOne(`${TESTING_BASE_URL}/auth/logout`).flush({}); - httpTestingController.expectOne(`${TESTING_BASE_URL}/portal-menu-links`).flush({}); + httpTestingController.expectOne(`${TESTING_BASE_URL}/portal-navigation-items?area=TOP_NAVBAR&loadChildren=false`).flush({}); expect(router.navigate).toHaveBeenCalledTimes(1); expect(router.navigate).toHaveBeenCalledWith(['']); expect(currentUserService.user()).toEqual({}); diff --git a/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.ts b/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.ts index ac66b444ebf..2764b9847cb 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.ts +++ b/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.ts @@ -20,7 +20,7 @@ import { switchMap, tap } from 'rxjs'; import { AuthService } from '../../services/auth.service'; import { CurrentUserService } from '../../services/current-user.service'; -import { PortalMenuLinksService } from '../../services/portal-menu-links.service'; +import { PortalNavigationItemsService } from '../../services/portal-navigation-items.service'; @Component({ selector: 'app-log-out', @@ -33,7 +33,7 @@ export class LogOutComponent implements OnInit { constructor( private readonly authService: AuthService, private readonly currentUserService: CurrentUserService, - private readonly portalMenuLinksService: PortalMenuLinksService, + private readonly portalNavigationItemsService: PortalNavigationItemsService, private readonly router: Router, ) {} @@ -43,7 +43,7 @@ export class LogOutComponent implements OnInit { .logout() .pipe( tap(_ => this.currentUserService.clear()), - switchMap(_ => this.portalMenuLinksService.loadCustomLinks()), + switchMap(_ => this.portalNavigationItemsService.loadTopNavBarItems()), tap(_ => this.router.navigate([''])), takeUntilDestroyed(this.destroyRef), ) diff --git a/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.html b/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.html index e1eb302728f..750542c49de 100644 --- a/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.html +++ b/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.html @@ -18,15 +18,19 @@ diff --git a/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.ts b/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.ts index 96876341de2..92059681086 100644 --- a/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.ts +++ b/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.ts @@ -18,8 +18,8 @@ import { MatAnchor, MatButton } from '@angular/material/button'; import { RouterLink } from '@angular/router'; import { isEmpty } from 'lodash'; +import { PortalNavigationItem } from '../../../entities/portal-navigation/portal-navigation'; import { User } from '../../../entities/user/user'; -import { PortalMenuLink } from '../../../services/portal-menu-links.service'; import { UserAvatarComponent } from '../../user-avatar/user-avatar.component'; import { NavBarButtonComponent } from '../nav-bar-button/nav-bar-button.component'; @@ -31,7 +31,7 @@ import { NavBarButtonComponent } from '../nav-bar-button/nav-bar-button.componen }) export class DesktopNavBarComponent { currentUser: InputSignal = input({}); - customLinks: InputSignal = input([]); + topBarNavigationItems: InputSignal = input([]); protected isLoggedIn = computed(() => { return !isEmpty(this.currentUser()); }); diff --git a/gravitee-apim-portal-webui-next/src/components/nav-bar/mobile-nav-bar/mobile-nav-bar.component.html b/gravitee-apim-portal-webui-next/src/components/nav-bar/mobile-nav-bar/mobile-nav-bar.component.html index f49f2eb89ba..13b89aaf694 100644 --- a/gravitee-apim-portal-webui-next/src/components/nav-bar/mobile-nav-bar/mobile-nav-bar.component.html +++ b/gravitee-apim-portal-webui-next/src/components/nav-bar/mobile-nav-bar/mobile-nav-bar.component.html @@ -37,10 +37,19 @@ } Catalog Guides - @for (link of customLinks(); track link.id) { - - {{ link.name }} - + @for (navigationItem of topBarNavigationItems(); track navigationItem.id) { + @switch (navigationItem.type) { + @case ('LINK') { + + {{ navigationItem.title }} + + } + @default { + + {{ navigationItem.title }} + + } + } }
diff --git a/gravitee-apim-portal-webui-next/src/components/nav-bar/mobile-nav-bar/mobile-nav-bar.component.ts b/gravitee-apim-portal-webui-next/src/components/nav-bar/mobile-nav-bar/mobile-nav-bar.component.ts index 18858d5fe5e..acbca126d03 100644 --- a/gravitee-apim-portal-webui-next/src/components/nav-bar/mobile-nav-bar/mobile-nav-bar.component.ts +++ b/gravitee-apim-portal-webui-next/src/components/nav-bar/mobile-nav-bar/mobile-nav-bar.component.ts @@ -22,8 +22,8 @@ import { isEmpty } from 'lodash'; import { catchError, map } from 'rxjs'; import { of } from 'rxjs/internal/observable/of'; +import { PortalNavigationItem } from '../../../entities/portal-navigation/portal-navigation'; import { User } from '../../../entities/user/user'; -import { PortalMenuLink } from '../../../services/portal-menu-links.service'; import { PortalService } from '../../../services/portal.service'; @Component({ @@ -34,7 +34,7 @@ import { PortalService } from '../../../services/portal.service'; }) export class MobileNavBarComponent { currentUser: InputSignal = input({}); - customLinks: InputSignal = input([]); + topBarNavigationItems: InputSignal = input([]); hasHomepage = toSignal( inject(PortalService) .getPortalHomepages() diff --git a/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.html b/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.html index b497e95c0c9..6b9f5f87e2b 100644 --- a/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.html +++ b/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.html @@ -19,9 +19,9 @@ @if (!forceLogin() || isLoggedIn()) { @if (isMobile()) { - + } @else { - + } } diff --git a/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.spec.ts b/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.spec.ts index 027dd638336..9f8481eea8a 100644 --- a/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.spec.ts +++ b/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.spec.ts @@ -18,14 +18,16 @@ import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { HttpTestingController } from '@angular/common/http/testing'; -import { ComponentRef } from '@angular/core'; +import { ComponentRef, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatButtonHarness } from '@angular/material/button/testing'; import { of } from 'rxjs'; import { NavBarComponent } from './nav-bar.component'; import { PortalPage } from '../../entities/portal/portal-page'; +import { PortalNavigationItem, PortalNavigationLink } from '../../entities/portal-navigation/portal-navigation'; import { fakeUser } from '../../entities/user/user.fixtures'; +import { PortalNavigationItemsService } from '../../services/portal-navigation-items.service'; import { AppTestingModule, TESTING_BASE_URL } from '../../testing/app-testing.module'; import { DivHarness } from '../../testing/div.harness'; @@ -34,21 +36,27 @@ describe('NavBarComponent', () => { let harnessLoader: HarnessLoader; let componentRef: ComponentRef; let httpTestingController: HttpTestingController; - const customLinks = [ + const topBarNavigationItems: PortalNavigationItem[] = [ { id: 'link-id-1', - type: 'external', - name: 'link-name-1', - target: 'link-target-1', + organizationId: 'DEFAULT', + environmentId: 'DEFAULT', + title: 'link-name-1', + type: 'LINK', + area: 'TOP_NAVBAR', order: 1, - }, + url: 'link-target-1', + } as PortalNavigationLink, { id: 'link-id-2', - type: 'external', - name: 'link-name-2', - target: 'link-target-2', + organizationId: 'DEFAULT', + environmentId: 'DEFAULT', + title: 'link-name-2', + type: 'LINK', + area: 'TOP_NAVBAR', order: 2, - }, + url: 'link-target-2', + } as PortalNavigationLink, ]; const init = async (isMobile: boolean = false) => { @@ -56,9 +64,19 @@ describe('NavBarComponent', () => { observe: () => of({ matches: isMobile, breakpoints: { [Breakpoints.XSmall]: isMobile } }), }; + const portalNavigationItemsServiceMock: Partial = { + loadTopNavBarItems: () => of([]), + topNavbar: { + set: () => {}, + } as unknown as WritableSignal, + }; + await TestBed.configureTestingModule({ imports: [NavBarComponent, AppTestingModule], - providers: [{ provide: BreakpointObserver, useValue: mockBreakpointObserver }], + providers: [ + { provide: BreakpointObserver, useValue: mockBreakpointObserver }, + { provide: PortalNavigationItemsService, useValue: portalNavigationItemsServiceMock }, + ], }).compileComponents(); fixture = TestBed.createComponent(NavBarComponent); @@ -86,7 +104,7 @@ describe('NavBarComponent', () => { }); it('should not show links if user is not connected and login is forced', async () => { - componentRef.setInput('customLinks', customLinks); + componentRef.setInput('topBarNavigationItems', topBarNavigationItems); componentRef.setInput('forceLogin', true); const link1Anchor = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ text: 'link-name-1' })); expect(link1Anchor).not.toBeTruthy(); @@ -95,7 +113,7 @@ describe('NavBarComponent', () => { }); it('should show links if user is connected and login is forced', async () => { - componentRef.setInput('customLinks', customLinks); + componentRef.setInput('topBarNavigationItems', topBarNavigationItems); componentRef.setInput('forceLogin', true); componentRef.setInput('currentUser', fakeUser()); const link1Anchor = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ text: 'link-name-1' })); @@ -105,7 +123,7 @@ describe('NavBarComponent', () => { }); it('should show custom links if login is not forced', async () => { - componentRef.setInput('customLinks', customLinks); + componentRef.setInput('topBarNavigationItems', topBarNavigationItems); const link1Anchor = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ text: 'link-name-1' })); expect(link1Anchor).toBeTruthy(); const link2Anchor = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ text: 'link-name-2' })); @@ -149,7 +167,7 @@ describe('NavBarComponent', () => { it('should not show menu if user is not connected and login is forced', async () => { expectHomePage([]); - componentRef.setInput('customLinks', customLinks); + componentRef.setInput('topBarNavigationItems', topBarNavigationItems); componentRef.setInput('forceLogin', true); fixture.detectChanges(); @@ -160,7 +178,7 @@ describe('NavBarComponent', () => { it('should show links if user is connected and login is forced', async () => { expectHomePage([]); componentRef.setInput('currentUser', fakeUser()); - componentRef.setInput('customLinks', customLinks); + componentRef.setInput('topBarNavigationItems', topBarNavigationItems); componentRef.setInput('forceLogin', true); fixture.detectChanges(); @@ -175,7 +193,7 @@ describe('NavBarComponent', () => { it('should show custom links if login is not forced', async () => { expectHomePage([]); - componentRef.setInput('customLinks', customLinks); + componentRef.setInput('topBarNavigationItems', topBarNavigationItems); fixture.detectChanges(); const menuButton = await harnessLoader.getHarness(MatButtonHarness.with({ selector: '.mobile-menu__button' })); diff --git a/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.ts b/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.ts index 7e5920b6a04..2370bb4dfa7 100644 --- a/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.ts +++ b/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.ts @@ -17,9 +17,9 @@ import { Component, computed, inject, input, InputSignal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { isEmpty } from 'lodash'; +import { PortalNavigationItem } from '../../entities/portal-navigation/portal-navigation'; import { User } from '../../entities/user/user'; import { ObservabilityBreakpointService } from '../../services/observability-breakpoint.service'; -import { PortalMenuLink } from '../../services/portal-menu-links.service'; import { CompanyTitleComponent } from '../company-title/company-title.component'; import { DesktopNavBarComponent } from './desktop-nav-bar/desktop-nav-bar.component'; import { MobileNavBarComponent } from './mobile-nav-bar/mobile-nav-bar.component'; @@ -31,7 +31,7 @@ import { MobileNavBarComponent } from './mobile-nav-bar/mobile-nav-bar.component styleUrls: ['./nav-bar.component.scss'], }) export class NavBarComponent { - customLinks: InputSignal = input([]); + topBarNavigationItems: InputSignal = input([]); currentUser: InputSignal = input({}); forceLogin: InputSignal = input(false); logo: InputSignal = input(''); diff --git a/gravitee-apim-portal-webui-next/src/entities/portal-navigation/portal-navigation.ts b/gravitee-apim-portal-webui-next/src/entities/portal-navigation/portal-navigation.ts new file mode 100644 index 00000000000..d94cdaa070b --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/entities/portal-navigation/portal-navigation.ts @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type PortalArea = 'HOMEPAGE' | 'TOP_NAVBAR'; + +export type PortalNavigationType = 'PAGE' | 'FOLDER' | 'LINK'; + +export interface BasePortalNavigationItem { + id: string; + organizationId: string; + environmentId: string; + title: string; + type: PortalNavigationType; + area: PortalArea; + parentId?: string | null; + order: number; +} + +export interface PortalNavigationPage extends BasePortalNavigationItem { + type: 'PAGE'; + portalPageContentId: string; +} + +export interface PortalNavigationFolder extends BasePortalNavigationItem { + type: 'FOLDER'; +} + +export interface PortalNavigationLink extends BasePortalNavigationItem { + type: 'LINK'; + url: string; +} + +export type PortalNavigationItem = PortalNavigationPage | PortalNavigationFolder | PortalNavigationLink; diff --git a/gravitee-apim-portal-webui-next/src/services/portal-menu-links.service.spec.ts b/gravitee-apim-portal-webui-next/src/services/portal-menu-links.service.spec.ts deleted file mode 100644 index da4caad5568..00000000000 --- a/gravitee-apim-portal-webui-next/src/services/portal-menu-links.service.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (C) 2024 The Gravitee team (http://gravitee.io) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { HttpTestingController } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; - -import { PortalMenuLink, PortalMenuLinksService } from './portal-menu-links.service'; -import { AppTestingModule, TESTING_BASE_URL } from '../testing/app-testing.module'; - -describe('PortalMenuLinksService', () => { - let service: PortalMenuLinksService; - let httpTestingController: HttpTestingController; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [AppTestingModule], - }); - service = TestBed.inject(PortalMenuLinksService); - httpTestingController = TestBed.inject(HttpTestingController); - }); - - afterEach(() => { - httpTestingController.verify(); - }); - - it('should return links list', done => { - const linkLists: PortalMenuLink[] = [ - { - id: 'link-id-1', - type: 'external', - name: 'link-name-1', - target: 'link-target-1', - order: 1, - }, - { - id: 'link-id-2', - type: 'external', - name: 'link-name-2', - target: 'link-target-2', - order: 2, - }, - ]; - service.loadCustomLinks().subscribe(response => { - expect(response).toMatchObject(linkLists); - done(); - }); - - const req = httpTestingController.expectOne(`${TESTING_BASE_URL}/portal-menu-links`); - expect(req.request.method).toEqual('GET'); - - req.flush(linkLists); - - expect(service.links()).toEqual(linkLists); - }); -}); diff --git a/gravitee-apim-portal-webui-next/src/services/portal-navigation-items.service.spec.ts b/gravitee-apim-portal-webui-next/src/services/portal-navigation-items.service.spec.ts new file mode 100644 index 00000000000..4d6c32de841 --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/services/portal-navigation-items.service.spec.ts @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2025 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ConfigService } from './config.service'; +import { PortalNavigationItemsService } from './portal-navigation-items.service'; +import { PortalNavigationItem } from '../entities/portal-navigation/portal-navigation'; + +describe('PortalNavigationItemsService', () => { + let service: PortalNavigationItemsService; + let httpMock: HttpTestingController; + const baseURL = 'http://localhost/portal/environments/DEFAULT'; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [{ provide: ConfigService, useValue: { baseURL } }], + }); + + service = TestBed.inject(PortalNavigationItemsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should load top navbar items and update topNavbar signal', done => { + const mockItems: PortalNavigationItem[] = [ + { + id: '1', + organizationId: 'org1', + environmentId: 'env1', + title: 'Home', + type: 'PAGE', + area: 'TOP_NAVBAR', + order: 0, + portalPageContentId: 'content1', + }, + { + id: '2', + organizationId: 'org1', + environmentId: 'env1', + title: 'APIs', + type: 'LINK', + area: 'TOP_NAVBAR', + order: 1, + url: '/apis', + }, + ]; + + service.loadTopNavBarItems().subscribe(items => { + expect(items).toEqual(mockItems); + expect(service.topNavbar()).toEqual(mockItems); + done(); + }); + + const req = httpMock.expectOne( + r => + r.method === 'GET' && + r.url === `${baseURL}/portal-navigation-items` && + r.params.get('area') === 'TOP_NAVBAR' && + r.params.get('loadChildren') === 'false', + ); + + req.flush(mockItems); + }); + + it('should set topNavbar to empty array on HTTP error', done => { + // set a non-empty value first to ensure it gets replaced + service.topNavbar.set([ + { + id: 'x', + organizationId: 'org1', + environmentId: 'env1', + title: 'old', + type: 'FOLDER', + area: 'TOP_NAVBAR', + order: 2, + }, + ]); + + service.loadTopNavBarItems().subscribe(items => { + expect(items).toEqual([]); + expect(service.topNavbar()).toEqual([]); + done(); + }); + + const req = httpMock.expectOne( + r => + r.method === 'GET' && + r.url === `${baseURL}/portal-navigation-items` && + r.params.get('area') === 'TOP_NAVBAR' && + r.params.get('loadChildren') === 'false', + ); + + req.flush('Server error', { status: 500, statusText: 'Server Error' }); + }); +}); diff --git a/gravitee-apim-portal-webui-next/src/services/portal-menu-links.service.ts b/gravitee-apim-portal-webui-next/src/services/portal-navigation-items.service.ts similarity index 55% rename from gravitee-apim-portal-webui-next/src/services/portal-menu-links.service.ts rename to gravitee-apim-portal-webui-next/src/services/portal-navigation-items.service.ts index 0d87ce26134..0040e15f920 100644 --- a/gravitee-apim-portal-webui-next/src/services/portal-menu-links.service.ts +++ b/gravitee-apim-portal-webui-next/src/services/portal-navigation-items.service.ts @@ -19,32 +19,32 @@ import { catchError, Observable, tap } from 'rxjs'; import { of } from 'rxjs/internal/observable/of'; import { ConfigService } from './config.service'; - -export type PortalMenuLinkType = 'external'; - -export interface PortalMenuLink { - id: string; - type: PortalMenuLinkType; - name: string; - target: string; - order: number; -} +import { PortalArea, PortalNavigationItem } from '../entities/portal-navigation/portal-navigation'; @Injectable({ providedIn: 'root', }) -export class PortalMenuLinksService { - public links: WritableSignal = signal([]); +export class PortalNavigationItemsService { + public topNavbar: WritableSignal = signal([]); constructor( private readonly http: HttpClient, - private configService: ConfigService, + private readonly configService: ConfigService, ) {} - loadCustomLinks(): Observable { - return this.http.get(`${this.configService.baseURL}/portal-menu-links`).pipe( - catchError(_ => of([])), - tap(value => this.links.set(value)), - ); + loadNavigationItems(area: PortalArea, loadChildren: boolean = true, parentId?: string): Observable { + const params = { + ...(parentId ? { parentId } : {}), + area, + loadChildren, + }; + + return this.http + .get(`${this.configService.baseURL}/portal-navigation-items`, { params }) + .pipe(catchError(_ => of([]))); + } + + loadTopNavBarItems(): Observable { + return this.loadNavigationItems('TOP_NAVBAR', false).pipe(tap(value => this.topNavbar.set(value))); } }