diff --git a/package.json b/package.json index da85128..c089c78 100644 --- a/package.json +++ b/package.json @@ -57,4 +57,4 @@ "typescript": "5.9.3", "zone.js": "^0.15.1" } -} +} \ No newline at end of file diff --git a/projects/lib/portal-options/services/custom-global-nodes.service.ts b/projects/lib/portal-options/services/custom-global-nodes.service.ts index 72fac90..8e55b55 100644 --- a/projects/lib/portal-options/services/custom-global-nodes.service.ts +++ b/projects/lib/portal-options/services/custom-global-nodes.service.ts @@ -2,7 +2,12 @@ import { kcpRootOrgsPath } from '../models/constants'; import { PortalNodeContext } from '../models/luigi-context'; import { PortalLuigiNode } from '../models/luigi-node'; import { inject } from '@angular/core'; -import { CustomGlobalNodesService, I18nService } from '@openmfp/portal-ui-lib'; +import { + CustomGlobalNodesService, + EntityType, + I18nService, + NodeContext, +} from '@openmfp/portal-ui-lib'; export class CustomGlobalNodesServiceImpl implements CustomGlobalNodesService { private i18nService = inject(I18nService); @@ -25,6 +30,28 @@ export class CustomGlobalNodesServiceImpl implements CustomGlobalNodesService { kcpPath: kcpRootOrgsPath, } as PortalNodeContext, }, + { + pathSegment: 'error', + order: '1000', + hideFromNav: true, + context: {} as PortalNodeContext, + children: [ + { + pathSegment: ':id', + entityType: EntityType.ENTITY_ERROR, + hideFromNav: true, + hideSideNav: true, + viewUrl: '/assets/platform-mesh-portal-ui-wc.js#error-component', + context: { + id: ':id', + translationTable: this.i18nService.translationTable, + } as any as NodeContext, + webcomponent: { + selfRegistered: true, + }, + }, + ], + }, { pathSegment: 'users', showBreadcrumbs: false, diff --git a/projects/lib/portal-options/services/router-config.service.spec.ts b/projects/lib/portal-options/services/router-config.service.spec.ts index 0863fdc..b75325b 100644 --- a/projects/lib/portal-options/services/router-config.service.spec.ts +++ b/projects/lib/portal-options/services/router-config.service.spec.ts @@ -92,7 +92,7 @@ describe('CustomRoutingConfigServiceImpl', () => { const result = await config.pageNotFoundHandler(); expect(result).toEqual({ - redirectTo: 'welcome', + redirectTo: 'error/404', keepURL: true, }); }); @@ -111,7 +111,7 @@ describe('CustomRoutingConfigServiceImpl', () => { const result = await config.pageNotFoundHandler(); expect(result).toEqual({ - redirectTo: 'error/404', + redirectTo: 'welcome', keepURL: true, }); }); @@ -171,7 +171,7 @@ describe('CustomRoutingConfigServiceImpl', () => { const result = await config.pageNotFoundHandler(); expect(result).toEqual({ - redirectTo: 'error/404', + redirectTo: 'welcome', keepURL: true, }); }); @@ -190,7 +190,7 @@ describe('CustomRoutingConfigServiceImpl', () => { const result = await config.pageNotFoundHandler(); expect(result).toEqual({ - redirectTo: 'welcome', + redirectTo: 'error/404', keepURL: true, }); }); @@ -215,7 +215,7 @@ describe('CustomRoutingConfigServiceImpl', () => { const result = await config.pageNotFoundHandler(); expect(result).toEqual({ - redirectTo: 'error/404', + redirectTo: 'welcome', keepURL: true, }); }); diff --git a/projects/lib/portal-options/services/router-config.service.ts b/projects/lib/portal-options/services/router-config.service.ts index 4b6685f..fa9de43 100644 --- a/projects/lib/portal-options/services/router-config.service.ts +++ b/projects/lib/portal-options/services/router-config.service.ts @@ -20,12 +20,12 @@ export class CustomRoutingConfigServiceImpl implements RoutingConfigService { getRoutingConfig(): any { return { - pageNotFoundHandler: async () => { + pageNotFoundHandler: () => { if (!this.envConfig?.baseDomain) { return this.redirectTo('error/404'); } - if (window.location.hostname !== this.envConfig.baseDomain) { + if (window.location.hostname === this.envConfig.baseDomain) { return this.redirectTo('welcome'); } diff --git a/projects/wc/_mocks_/ui5-mock.ts b/projects/wc/_mocks_/ui5-mock.ts index dde2109..92f5553 100644 --- a/projects/wc/_mocks_/ui5-mock.ts +++ b/projects/wc/_mocks_/ui5-mock.ts @@ -40,4 +40,26 @@ jest.mock('@ui5/webcomponents-icons/dist/hide.js', () => ({}), { }); jest.mock('@ui5/webcomponents-icons/dist/show.js', () => ({}), { virtual: true, -}); \ No newline at end of file +}); + +jest.mock( + '@ui5/webcomponents-fiori/dist/illustrations/NoEntries.js', + () => ({}), + { + virtual: true, + }, +); +jest.mock( + '@ui5/webcomponents-fiori/dist/illustrations/UnableToLoad.js', + () => ({}), + { + virtual: true, + }, +); +jest.mock( + '@ui5/webcomponents-fiori/dist/illustrations/tnt/UnsuccessfulAuth.js', + () => ({}), + { + virtual: true, + }, +); \ No newline at end of file diff --git a/projects/wc/src/app/components/error/error.component.html b/projects/wc/src/app/components/error/error.component.html new file mode 100644 index 0000000..cdcc2e3 --- /dev/null +++ b/projects/wc/src/app/components/error/error.component.html @@ -0,0 +1,23 @@ +@let configVM = config(); +@if (configVM.scene) { +
+
+ + {{configVM.illustratedMessageTitle}} +
+

{{configVM.illustratedMessageText}}

+
+
+ @for (button of configVM.buttons; track button.url) { + + {{ button.label }} + + } +
+
+
+
+} diff --git a/projects/wc/src/app/components/error/error.component.spec.ts b/projects/wc/src/app/components/error/error.component.spec.ts new file mode 100644 index 0000000..3b4bd8a --- /dev/null +++ b/projects/wc/src/app/components/error/error.component.spec.ts @@ -0,0 +1,119 @@ +import { ErrorComponent } from './error.component'; +import { ButtonConfig } from './models/error.model'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { I18nService, LuigiCoreService } from '@openmfp/portal-ui-lib'; +import { + ButtonComponent, + IllustratedMessageComponent, + TitleComponent, +} from '@ui5/webcomponents-ngx'; + +describe('ErrorComponent', () => { + let component: ErrorComponent; + let fixture: ComponentFixture; + let i18nServiceMock: jest.Mocked; + let luigiCoreServiceMock: jest.Mocked; + + beforeEach(async () => { + i18nServiceMock = { + getTranslationAsync: jest.fn().mockResolvedValue('translated text'), + translationTable: {}, + } as any; + + luigiCoreServiceMock = { + navigation: jest.fn().mockReturnValue({ + navigate: jest.fn(), + }), + showAlert: jest.fn(), + } as any; + + await TestBed.configureTestingModule({ + imports: [ + ErrorComponent, + IllustratedMessageComponent, + ButtonComponent, + TitleComponent, + ], + providers: [ + { provide: I18nService, useValue: i18nServiceMock }, + { provide: LuigiCoreService, useValue: luigiCoreServiceMock }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ErrorComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('goTo', () => { + it('should open URL in new tab', () => { + const windowSpy = jest.spyOn(window, 'open').mockImplementation(); + const button: ButtonConfig = { url: 'https://test.com' }; + + component.goTo(button); + expect(windowSpy).toHaveBeenCalledWith('https://test.com', '_blank'); + }); + + it('should navigate using LuigiCore when route is provided', () => { + const button: ButtonConfig = { route: { context: 'test-route' } }; + const navigateSpy = jest.fn(); + jest + .spyOn(luigiCoreServiceMock, 'navigation') + .mockReturnValue({ navigate: navigateSpy }); + + component.goTo(button); + expect(navigateSpy).toHaveBeenCalledWith('/test-route'); + }); + }); + + describe('setSceneConfig', () => { + it('should set 404 config', async () => { + const testContext = { + id: '404', + translationTable: {}, + }; + + fixture.componentRef.setInput('context', testContext); + + await component.ngOnInit(); + expect(component.config().scene).toBe('NoEntries'); + expect(component.config().illustratedMessageTitle).toBe('translated text'); + expect(component.config().illustratedMessageText).toBe('translated text'); + expect(component.config().buttons).toBeDefined(); + }); + + it('should set 403 config', async () => { + const testContext = { + id: '403', + translationTable: {}, + }; + + fixture.componentRef.setInput('context', testContext); + + await component.ngOnInit(); + expect(component.config().scene).toBe('tnt/UnsuccessfulAuth'); + expect(component.config().illustratedMessageTitle).toBe(''); + expect(component.config().illustratedMessageText).toBe('translated text'); + expect(component.config().buttons).toBeDefined(); + expect(component.config().buttons?.length).toBe(2); + }); + + it('should set default error config for unknown error code', async () => { + const testContext = { + id: '500', + translationTable: {}, + }; + + fixture.componentRef.setInput('context', testContext); + + await component.ngOnInit(); + expect(component.config().scene).toBe('UnableToLoad'); + expect(component.config().illustratedMessageTitle).toBe('translated text'); + expect(component.config().illustratedMessageText).toBe(''); + expect(component.config().buttons).toBeDefined(); + }); + }); +}); diff --git a/projects/wc/src/app/components/error/error.component.ts b/projects/wc/src/app/components/error/error.component.ts new file mode 100644 index 0000000..174e0b0 --- /dev/null +++ b/projects/wc/src/app/components/error/error.component.ts @@ -0,0 +1,121 @@ +import { ButtonConfig, ErrorConfig } from './models/error.model'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnInit, + ViewEncapsulation, + inject, + input, + signal, +} from '@angular/core'; +import { I18nService, LuigiCoreService } from '@openmfp/portal-ui-lib'; +import '@ui5/webcomponents-fiori/dist/illustrations/NoEntries.js'; +import '@ui5/webcomponents-fiori/dist/illustrations/UnableToLoad.js'; +import '@ui5/webcomponents-fiori/dist/illustrations/tnt/UnsuccessfulAuth.js'; +import { + ButtonComponent, + IllustratedMessageComponent, + TitleComponent, +} from '@ui5/webcomponents-ngx'; + +@Component({ + selector: 'app-error', + standalone: true, + templateUrl: './error.component.html', + encapsulation: ViewEncapsulation.ShadowDom, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IllustratedMessageComponent, ButtonComponent, TitleComponent], +}) +export class ErrorComponent implements OnInit { + private i18nService = inject(I18nService); + private luigiCoreService = inject(LuigiCoreService); + + public context = input.required(); + + config = signal({ + scene: 'UnableToLoad', + illustratedMessageTitle: '', + illustratedMessageText: '', + buttons: [], + }); + + async ngOnInit() { + await this.setSceneConfig(); + } + + goTo(button: ButtonConfig): void { + if (button.url) { + window.open(button.url, '_blank'); + } else if (button.route) { + this.luigiCoreService.navigation().navigate(`/${button.route.context}`); + } + } + + private async setSceneConfig() { + const nodeContext = this.context(); + switch (+nodeContext.id) { + case 403: { + this.config.set(await this.getError403Config()); + break; + } + case 404: { + this.config.set(await this.getError404Config()); + break; + } + default: { + this.config.set(await this.getErrorDefaultConfig()); + } + } + } + + private async getError404Config(): Promise { + return { + scene: 'NoEntries', + illustratedMessageTitle: await this.i18nService.getTranslationAsync( + 'ERROR_CONTENT_NOT_FOUND_TITLE', + ), + illustratedMessageText: await this.i18nService.getTranslationAsync( + 'ERROR_CONTENT_NOT_FOUND_TEXT', + ), + buttons: [], + }; + } + + private async getError403Config(): Promise { + const illustratedMessageText = await this.i18nService.getTranslationAsync( + 'ERROR_CONTENT_NOT_ALLOWED_NO_PROJECT_MEMBER_TEXT', + ); + + return { + scene: 'tnt/UnsuccessfulAuth', + illustratedMessageTitle: '', + illustratedMessageText, + buttons: [ + { + url: '', + label: await this.i18nService.getTranslationAsync( + 'ERROR_CONTENT_NOT_ALLOWED_JOIN_PROJECT_BUTTON', + ), + }, + { + url: '', + label: await this.i18nService.getTranslationAsync( + 'ERROR_CONTENT_NOT_ALLOWED_VIEW_PROJECT_BUTTON', + ), + }, + ], + }; + } + + private async getErrorDefaultConfig(): Promise { + return { + scene: 'UnableToLoad', + illustratedMessageTitle: await this.i18nService.getTranslationAsync( + 'ERROR_UNIDENTIFIED_TITLE', + ), + illustratedMessageText: '', + buttons: [], + }; + } +} diff --git a/projects/wc/src/app/components/error/models/error.model.ts b/projects/wc/src/app/components/error/models/error.model.ts new file mode 100644 index 0000000..ed14364 --- /dev/null +++ b/projects/wc/src/app/components/error/models/error.model.ts @@ -0,0 +1,14 @@ +export interface ErrorConfig { + scene: string; + illustratedMessageTitle: string; + illustratedMessageText: string; + buttons: ButtonConfig[]; +} + +export interface ButtonConfig { + url?: string; + label?: string; + route?: { + context: string; + }; +} diff --git a/projects/wc/src/app/initializers/luigi-wc-initializer.spec.ts b/projects/wc/src/app/initializers/luigi-wc-initializer.spec.ts index 710e4d3..01779bf 100644 --- a/projects/wc/src/app/initializers/luigi-wc-initializer.spec.ts +++ b/projects/wc/src/app/initializers/luigi-wc-initializer.spec.ts @@ -4,6 +4,7 @@ import { OrganizationManagementComponent, WelcomeComponent, } from '../components'; +import { ErrorComponent } from '../components/error/error.component'; import * as wc from '../utils/wc'; import { provideLuigiWebComponents } from './luigi-wc-initializer'; import { ApplicationInitStatus } from '@angular/core'; @@ -30,6 +31,7 @@ describe('provideLuigiWebComponents', () => { 'generic-detail-view': DetailViewComponent, 'organization-management': OrganizationManagementComponent, 'welcome-view': WelcomeComponent, + 'error-component': ErrorComponent, } as Record; // Validate first arg equals the components map diff --git a/projects/wc/src/app/initializers/luigi-wc-initializer.ts b/projects/wc/src/app/initializers/luigi-wc-initializer.ts index a443ed4..350c5c5 100644 --- a/projects/wc/src/app/initializers/luigi-wc-initializer.ts +++ b/projects/wc/src/app/initializers/luigi-wc-initializer.ts @@ -4,6 +4,7 @@ import { OrganizationManagementComponent, WelcomeComponent, } from '../components'; +import { ErrorComponent } from '../components/error/error.component'; import { registerLuigiWebComponents } from '../utils/wc'; import { Injector, inject, provideAppInitializer } from '@angular/core'; @@ -16,6 +17,7 @@ export const provideLuigiWebComponents = () => 'generic-detail-view': DetailViewComponent, 'organization-management': OrganizationManagementComponent, 'welcome-view': WelcomeComponent, + 'error-component': ErrorComponent, }, injector, );