From 68f50b911d764cadb8f5120c8b7a14af5a79b99a Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Tue, 2 Dec 2025 12:38:28 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=201991:=20=E2=9C=A8=20feat(navigati?= =?UTF-8?q?on):=20implement=20title=20management=20and=20enhance=20tab=20s?= =?UTF-8?q?ystem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ feat(navigation): implement title management and enhance tab system This commit introduces a comprehensive title management system and extends the tab functionality with subtitle support, improving navigation clarity and user experience across the application. Key changes: Title Management System: - Add @isa/common/title-management library with dual approach: - IsaTitleStrategy for route-based static titles - usePageTitle() for component-based dynamic titles - Implement TitleRegistryService for nested component hierarchies - Automatic ISA prefix addition and TabService integration - Comprehensive test coverage (1,158 lines of tests) Tab System Enhancement: - Add subtitle field to tab schema for additional context - Update TabService API (addTab, patchTab) to support subtitles - Extend Zod schemas with subtitle validation - Update documentation with usage examples Routing Modernization: - Consolidate route guards using ActivateProcessIdWithConfigKeyGuard - Replace 4+ specific guards with generic config-key-based approach - Add title attributes to 100+ routes across all modules - Remove deprecated ProcessIdGuard in favor of ActivateProcessIdGuard Code Cleanup: - Remove deprecated preview component and related routes - Clean up unused imports and exports - Update TypeScript path aliases Dependencies: - Update package.json and package-lock.json - Add @isa/common/title-management to tsconfig path mappings Refs: #5351, #5418, #5419, #5420 --- apps/isa-app/src/app/app-routing.module.ts | 53 +- apps/isa-app/src/app/app.module.ts | 5 +- .../app/guards/activate-process-id.guard.ts | 129 +-- .../app/guards/can-activate-goods-in.guard.ts | 68 +- apps/isa-app/src/app/preview/index.ts | 3 - .../src/app/preview/preview.component.css | 3 - .../src/app/preview/preview.component.html | 10 - .../src/app/preview/preview.component.ts | 56 -- apps/isa-app/src/app/resolvers/index.ts | 6 +- apps/isa-app/src/config/config.json | 173 ++-- .../core/application/application.service.ts | 370 +++++---- .../checkout/store/domain-checkout.reducer.ts | 4 +- apps/isa-app/src/page/assortment/routes.ts | 33 +- .../catalog/page-catalog-routing.module.ts | 260 +++--- .../checkout/page-checkout-routing.module.ts | 4 + apps/isa-app/src/page/customer/routes.ts | 357 +++++---- .../dashboard/dashboard-routing-module.ts | 33 +- .../page/goods-in/good-in-routing.module.ts | 86 +- .../src/page/package-inspection/routes.ts | 46 +- .../pickup-shelf/pickup-shelf-in/routes.ts | 314 ++++---- .../pickup-shelf/pickup-shelf-out/routes.ts | 182 +++-- .../page-task-calendar-routing.module.ts | 63 +- .../feature/reward-catalog/src/lib/routes.ts | 1 + .../src/lib/routes.ts | 1 + .../reward-shopping-cart/src/lib/routes.ts | 1 + libs/common/title-management/README.md | 600 ++++++++++++++ .../common/title-management/eslint.config.cjs | 34 + libs/common/title-management/project.json | 20 + libs/common/title-management/src/index.ts | 3 + .../src/lib/isa-title.strategy.spec.ts | 157 ++++ .../src/lib/isa-title.strategy.ts | 89 +++ .../src/lib/title-management.types.ts | 29 + .../title-management/src/lib/title-prefix.ts | 14 + .../src/lib/title-registry.service.spec.ts | 255 ++++++ .../src/lib/title-registry.service.ts | 79 ++ .../src/lib/use-page-title.function.spec.ts | 746 ++++++++++++++++++ .../src/lib/use-page-title.function.ts | 203 +++++ .../common/title-management/src/test-setup.ts | 13 + libs/common/title-management/tsconfig.json | 30 + .../common/title-management/tsconfig.lib.json | 27 + .../title-management/tsconfig.spec.json | 29 + libs/common/title-management/vite.config.mts | 33 + .../user.storage-provider.ts | 10 +- libs/core/tabs/README.md | 5 + libs/core/tabs/src/lib/schemas.ts | 12 + libs/core/tabs/src/lib/tab.ts | 1 + .../feature/return-search/src/lib/routes.ts | 65 +- .../feature/remission-list/src/lib/routes.ts | 64 +- .../src/lib/routes.ts | 2 + package-lock.json | 6 +- tsconfig.base.json | 3 + 51 files changed, 3642 insertions(+), 1148 deletions(-) delete mode 100644 apps/isa-app/src/app/preview/index.ts delete mode 100644 apps/isa-app/src/app/preview/preview.component.css delete mode 100644 apps/isa-app/src/app/preview/preview.component.html delete mode 100644 apps/isa-app/src/app/preview/preview.component.ts create mode 100644 libs/common/title-management/README.md create mode 100644 libs/common/title-management/eslint.config.cjs create mode 100644 libs/common/title-management/project.json create mode 100644 libs/common/title-management/src/index.ts create mode 100644 libs/common/title-management/src/lib/isa-title.strategy.spec.ts create mode 100644 libs/common/title-management/src/lib/isa-title.strategy.ts create mode 100644 libs/common/title-management/src/lib/title-management.types.ts create mode 100644 libs/common/title-management/src/lib/title-prefix.ts create mode 100644 libs/common/title-management/src/lib/title-registry.service.spec.ts create mode 100644 libs/common/title-management/src/lib/title-registry.service.ts create mode 100644 libs/common/title-management/src/lib/use-page-title.function.spec.ts create mode 100644 libs/common/title-management/src/lib/use-page-title.function.ts create mode 100644 libs/common/title-management/src/test-setup.ts create mode 100644 libs/common/title-management/tsconfig.json create mode 100644 libs/common/title-management/tsconfig.lib.json create mode 100644 libs/common/title-management/tsconfig.spec.json create mode 100644 libs/common/title-management/vite.config.mts diff --git a/apps/isa-app/src/app/app-routing.module.ts b/apps/isa-app/src/app/app-routing.module.ts index dc720f9ce..cac01b1ef 100644 --- a/apps/isa-app/src/app/app-routing.module.ts +++ b/apps/isa-app/src/app/app-routing.module.ts @@ -1,4 +1,4 @@ -import { isDevMode, NgModule } from '@angular/core'; +import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { CanActivateCartGuard, @@ -7,23 +7,17 @@ import { CanActivateCustomerOrdersGuard, CanActivateCustomerOrdersWithProcessIdGuard, CanActivateCustomerWithProcessIdGuard, - CanActivateGoodsInGuard, CanActivateProductGuard, CanActivateProductWithProcessIdGuard, - CanActivateTaskCalendarGuard, IsAuthenticatedGuard, } from './guards'; -import { CanActivateAssortmentGuard } from './guards/can-activate-assortment.guard'; -import { CanActivatePackageInspectionGuard } from './guards/can-activate-package-inspection.guard'; import { MainComponent } from './main.component'; -import { PreviewComponent } from './preview'; import { BranchSectionResolver, CustomerSectionResolver, ProcessIdResolver, } from './resolvers'; import { TokenLoginComponent, TokenLoginModule } from './token-login'; -import { ProcessIdGuard } from './guards/process-id.guard'; import { ActivateProcessIdGuard, ActivateProcessIdWithConfigKeyGuard, @@ -74,8 +68,12 @@ const routes: Routes = [ loadChildren: () => import('@page/catalog').then((m) => m.PageCatalogModule), canActivate: [CanActivateProductWithProcessIdGuard], - resolve: { processId: ProcessIdResolver }, + resolve: { + processId: ProcessIdResolver, + }, }, + // TODO: Check if order and :processId/order is still being used + // If not, remove these routes and the related guards and resolvers { path: 'order', loadChildren: () => @@ -87,7 +85,9 @@ const routes: Routes = [ loadChildren: () => import('@page/customer-order').then((m) => m.CustomerOrderModule), canActivate: [CanActivateCustomerOrdersWithProcessIdGuard], - resolve: { processId: ProcessIdResolver }, + resolve: { + processId: ProcessIdResolver, + }, }, { path: 'customer', @@ -100,7 +100,9 @@ const routes: Routes = [ loadChildren: () => import('@page/customer').then((m) => m.CustomerModule), canActivate: [CanActivateCustomerWithProcessIdGuard], - resolve: { processId: ProcessIdResolver }, + resolve: { + processId: ProcessIdResolver, + }, }, { path: 'cart', @@ -113,10 +115,13 @@ const routes: Routes = [ loadChildren: () => import('@page/checkout').then((m) => m.PageCheckoutModule), canActivate: [CanActivateCartWithProcessIdGuard], + resolve: { + processId: ProcessIdResolver, + }, }, { path: 'pickup-shelf', - canActivate: [ProcessIdGuard], + canActivate: [ActivateProcessIdGuard], // NOTE: This is a workaround for the canActivate guard not being called loadChildren: () => import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule), @@ -126,6 +131,9 @@ const routes: Routes = [ canActivate: [ActivateProcessIdGuard], loadChildren: () => import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule), + resolve: { + processId: ProcessIdResolver, + }, }, { path: '**', redirectTo: 'dashboard', pathMatch: 'full' }, ], @@ -141,7 +149,7 @@ const routes: Routes = [ import('@page/task-calendar').then( (m) => m.PageTaskCalendarModule, ), - canActivate: [CanActivateTaskCalendarGuard], + canActivate: [ActivateProcessIdWithConfigKeyGuard('taskCalendar')], }, { path: 'pickup-shelf', @@ -154,27 +162,23 @@ const routes: Routes = [ path: 'goods/in', loadChildren: () => import('@page/goods-in').then((m) => m.GoodsInModule), - canActivate: [CanActivateGoodsInGuard], + canActivate: [ActivateProcessIdWithConfigKeyGuard('goodsIn')], }, - // { - // path: 'remission', - // loadChildren: () => - // import('@page/remission').then((m) => m.PageRemissionModule), - // canActivate: [CanActivateRemissionGuard], - // }, { path: 'package-inspection', loadChildren: () => import('@page/package-inspection').then( (m) => m.PackageInspectionModule, ), - canActivate: [CanActivatePackageInspectionGuard], + canActivate: [ + ActivateProcessIdWithConfigKeyGuard('packageInspection'), + ], }, { path: 'assortment', loadChildren: () => import('@page/assortment').then((m) => m.AssortmentModule), - canActivate: [CanActivateAssortmentGuard], + canActivate: [ActivateProcessIdWithConfigKeyGuard('assortment')], }, { path: '**', redirectTo: 'task-calendar', pathMatch: 'full' }, ], @@ -243,13 +247,6 @@ const routes: Routes = [ }, ]; -if (isDevMode()) { - routes.unshift({ - path: 'preview', - component: PreviewComponent, - }); -} - @NgModule({ imports: [ RouterModule.forRoot(routes, { diff --git a/apps/isa-app/src/app/app.module.ts b/apps/isa-app/src/app/app.module.ts index e0e96751e..c793753a7 100644 --- a/apps/isa-app/src/app/app.module.ts +++ b/apps/isa-app/src/app/app.module.ts @@ -1,4 +1,5 @@ import { version } from '../../../../package.json'; +import { IsaTitleStrategy } from '@isa/common/title-management'; import { HTTP_INTERCEPTORS, provideHttpClient, @@ -56,7 +57,6 @@ import { ScanditScanAdapterModule, } from '@adapter/scan'; import * as Commands from './commands'; -import { PreviewComponent } from './preview'; import { NativeContainerService } from '@external/native-container'; import { ShellModule } from '@shared/shell'; import { MainComponent } from './main.component'; @@ -87,6 +87,7 @@ import { import { Store } from '@ngrx/store'; import { OAuthService } from 'angular-oauth2-oidc'; import z from 'zod'; +import { TitleStrategy } from '@angular/router'; import { TabNavigationService } from '@isa/core/tabs'; registerLocaleData(localeDe, localeDeExtra); @@ -270,7 +271,6 @@ const USER_SUB_FACTORY = () => { CoreCommandModule.forRoot(Object.values(Commands)), CoreLoggerModule.forRoot(), AppStoreModule, - PreviewComponent, AuthModule.forRoot(), CoreApplicationModule.forRoot(), UiModalModule.forRoot(), @@ -330,6 +330,7 @@ const USER_SUB_FACTORY = () => { useValue: 'EUR', }, provideUserSubFactory(USER_SUB_FACTORY), + { provide: TitleStrategy, useClass: IsaTitleStrategy }, ], }) export class AppModule {} diff --git a/apps/isa-app/src/app/guards/activate-process-id.guard.ts b/apps/isa-app/src/app/guards/activate-process-id.guard.ts index 9759d4dd1..83e67203e 100644 --- a/apps/isa-app/src/app/guards/activate-process-id.guard.ts +++ b/apps/isa-app/src/app/guards/activate-process-id.guard.ts @@ -1,47 +1,82 @@ -import { inject } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router'; -import { ApplicationService } from '@core/application'; -import { Config } from '@core/config'; -import { take } from 'rxjs/operators'; - -export const ActivateProcessIdGuard: CanActivateFn = async ( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot, -) => { - const application = inject(ApplicationService); - - const processIdStr = route.params.processId; - - if (!processIdStr) { - return false; - } - - const processId = Number(processIdStr); - - // Check if Process already exists - const process = await application.getProcessById$(processId).pipe(take(1)).toPromise(); - - if (!process) { - application.createCustomerProcess(processId); - } - - application.activateProcess(processId); - - return true; -}; - -export const ActivateProcessIdWithConfigKeyGuard: (key: string) => CanActivateFn = - (key) => async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { - const application = inject(ApplicationService); - const config = inject(Config); - - const processId = config.get(`process.ids.${key}`); - - if (isNaN(processId)) { - return false; - } - - application.activateProcess(processId); - - return true; - }; +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + RouterStateSnapshot, +} from '@angular/router'; +import { ApplicationService } from '@core/application'; +import { Config } from '@core/config'; +import { take } from 'rxjs/operators'; +import z from 'zod'; + +export const ActivateProcessIdGuard: CanActivateFn = async ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, +) => { + const application = inject(ApplicationService); + + const processIdStr = route.params.processId; + + if (!processIdStr) { + return false; + } + + const processId = Number(processIdStr); + + // Check if Process already exists + const process = await application + .getProcessById$(processId) + .pipe(take(1)) + .toPromise(); + + if (!process) { + application.createProcess({ + id: processId, + type: 'cart', + section: 'customer', + name: `Vorgang ${processId}`, + }); + } + + application.activateProcess(processId); + + return true; +}; + +export const ActivateProcessIdWithConfigKeyGuard: ( + key: string, +) => CanActivateFn = + (key) => + async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const application = inject(ApplicationService); + const config = inject(Config); + + const processId = config.get(`process.ids.${key}`, z.coerce.number()); + + if (typeof processId !== 'number' || isNaN(processId)) { + return false; + } + + const processTitle = config.get( + `process.titles.${key}`, + z.string().default(key), + ); + + const process = await application + .getProcessById$(processId) + .pipe(take(1)) + .toPromise(); + + if (!process) { + await application.createProcess({ + id: processId, + type: key, + section: 'customer', // Not important anymore + name: processTitle, + }); + } + + application.activateProcess(processId); + + return true; + }; diff --git a/apps/isa-app/src/app/guards/can-activate-goods-in.guard.ts b/apps/isa-app/src/app/guards/can-activate-goods-in.guard.ts index 24533cc01..bad7888b3 100644 --- a/apps/isa-app/src/app/guards/can-activate-goods-in.guard.ts +++ b/apps/isa-app/src/app/guards/can-activate-goods-in.guard.ts @@ -1,34 +1,34 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -import { ApplicationService } from '@core/application'; -import { Config } from '@core/config'; -import { first } from 'rxjs/operators'; -import { z } from 'zod'; - -@Injectable({ providedIn: 'root' }) -export class CanActivateGoodsInGuard { - constructor( - private readonly _applicationService: ApplicationService, - private readonly _config: Config, - ) {} - - async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { - const pid = this._config.get('process.ids.goodsIn', z.number()); - const process = await this._applicationService - .getProcessById$(pid) - .pipe(first()) - .toPromise(); - if (!process) { - await this._applicationService.createProcess({ - id: this._config.get('process.ids.goodsIn'), - type: 'goods-in', - section: 'branch', - name: '', - }); - } - this._applicationService.activateProcess( - this._config.get('process.ids.goodsIn'), - ); - return true; - } -} +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { ApplicationService } from '@core/application'; +import { Config } from '@core/config'; +import { first } from 'rxjs/operators'; +import { z } from 'zod'; + +@Injectable({ providedIn: 'root' }) +export class CanActivateGoodsInGuard { + constructor( + private readonly _applicationService: ApplicationService, + private readonly _config: Config, + ) {} + + async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + const pid = this._config.get('process.ids.goodsIn', z.number()); + const process = await this._applicationService + .getProcessById$(pid) + .pipe(first()) + .toPromise(); + if (!process) { + await this._applicationService.createProcess({ + id: this._config.get('process.ids.goodsIn'), + type: 'goods-in', + section: 'branch', + name: 'Abholfach', + }); + } + this._applicationService.activateProcess( + this._config.get('process.ids.goodsIn'), + ); + return true; + } +} diff --git a/apps/isa-app/src/app/preview/index.ts b/apps/isa-app/src/app/preview/index.ts deleted file mode 100644 index 5b19177c2..000000000 --- a/apps/isa-app/src/app/preview/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// start:ng42.barrel -export * from './preview.component'; -// end:ng42.barrel diff --git a/apps/isa-app/src/app/preview/preview.component.css b/apps/isa-app/src/app/preview/preview.component.css deleted file mode 100644 index 882123fb5..000000000 --- a/apps/isa-app/src/app/preview/preview.component.css +++ /dev/null @@ -1,3 +0,0 @@ -:host { - @apply grid min-h-screen content-center justify-center; -} diff --git a/apps/isa-app/src/app/preview/preview.component.html b/apps/isa-app/src/app/preview/preview.component.html deleted file mode 100644 index 0975604cc..000000000 --- a/apps/isa-app/src/app/preview/preview.component.html +++ /dev/null @@ -1,10 +0,0 @@ -

Platform: {{ platform | json }}

-
-

{{ appVersion }}

-
-

{{ userAgent }}

-
-

Navigator: {{ navigator | json }}

-
-
-

Device: {{ device }}

diff --git a/apps/isa-app/src/app/preview/preview.component.ts b/apps/isa-app/src/app/preview/preview.component.ts deleted file mode 100644 index 4933035c3..000000000 --- a/apps/isa-app/src/app/preview/preview.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Platform, PlatformModule } from '@angular/cdk/platform'; -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { BranchDTO } from '@generated/swagger/checkout-api'; -import { BehaviorSubject } from 'rxjs'; - -@Component({ - selector: 'app-preview', - templateUrl: 'preview.component.html', - styleUrls: ['preview.component.css'], - imports: [CommonModule, PlatformModule], -}) -export class PreviewComponent { - selectedBranch$ = new BehaviorSubject({}); - - get appVersion() { - return 'App Version: ' + (window.navigator as any).appVersion; - } - - get userAgent() { - return 'User Agent: ' + (window.navigator as any).userAgent; - } - - get navigator() { - const nav = {}; - for (const i in window.navigator) nav[i] = navigator[i]; - return nav; - } - - get platform() { - return this._platform; - } - - get device() { - const isIpadNative = this._platform.IOS && !this._platform.SAFARI; - const isIpadMini6Native = window?.navigator?.userAgent?.includes('Macintosh') && !this._platform.SAFARI; - const isNative = isIpadNative || isIpadMini6Native; - const isPWA = this._platform.IOS && this._platform.SAFARI; - const isDesktop = !isNative && !isPWA; - if (isNative) { - if (isIpadMini6Native) { - return 'IPAD mini 6 Native App'; - } else if (isIpadNative) { - return 'IPAD mini 2 Native App or IPAD mini 5 Native App'; - } - } else if (isPWA) { - return 'IPAD Safari PWA'; - } else if (isDesktop) return 'Desktop or Macintosh'; - } - - constructor(private readonly _platform: Platform) {} - - setNewBranch(branch: BranchDTO) { - this.selectedBranch$.next(branch); - } -} diff --git a/apps/isa-app/src/app/resolvers/index.ts b/apps/isa-app/src/app/resolvers/index.ts index 6c6b2b637..e2bdb9a65 100644 --- a/apps/isa-app/src/app/resolvers/index.ts +++ b/apps/isa-app/src/app/resolvers/index.ts @@ -1,4 +1,2 @@ -// start:ng42.barrel -export * from './process-id.resolver'; -export * from './section.resolver'; -// end:ng42.barrel +export * from './process-id.resolver'; +export * from './section.resolver'; diff --git a/apps/isa-app/src/config/config.json b/apps/isa-app/src/config/config.json index d3fe1a09f..1e709cba4 100644 --- a/apps/isa-app/src/config/config.json +++ b/apps/isa-app/src/config/config.json @@ -1,87 +1,86 @@ -{ - "title": "ISA - Local", - "silentRefresh": { - "interval": 300000 - }, - "debug": true, - "dev-scanner": true, - "@cdn/product-image": { - "url": "https://produktbilder.paragon-data.net" - }, - "@core/auth": { - "issuer": "https://sso-test.paragon-data.de", - "clientId": "isa-client", - "responseType": "code", - "scope": "openid profile cmf_user isa-isa-webapi isa-checkout-webapi isa-cat-webapi isa-ava-webapi isa-crm-webapi isa-review-webapi isa-kpi-webapi isa-oms-webapi isa-nbo-webapi isa-print-webapi eis-service isa-inv-webapi isa-wws-webapi", - "showDebugInformation": true - }, - "@core/logger": { - "logLevel": "debug" - }, - "@swagger/isa": { - "rootUrl": "https://isa-test.paragon-data.net/isa/v1" - }, - "@swagger/cat": { - "rootUrl": "https://isa-test.paragon-data.net/catsearch/v6" - }, - "@swagger/av": { - "rootUrl": "https://isa-test.paragon-data.net/ava/v6" - }, - "@swagger/checkout": { - "rootUrl": "https://isa-test.paragon-data.net/checkout/v6" - }, - "@swagger/crm": { - "rootUrl": "https://isa-test.paragon-data.net/crm/v6" - }, - "@swagger/oms": { - "rootUrl": "https://isa-test.paragon-data.net/oms/v6" - }, - "@swagger/print": { - "rootUrl": "https://isa-test.paragon-data.net/print/v1" - }, - "@swagger/eis": { - "rootUrl": "https://filialinformationsystem-test.paragon-systems.de/eiswebapi/v1" - }, - "@swagger/remi": { - "rootUrl": "https://isa-test.paragon-data.net/inv/v6" - }, - "@swagger/wws": { - "rootUrl": "https://isa-test.paragon-data.net/wws/v1" - }, - "@domain/checkout": { - "olaExpiration": "5m" - }, - "hubs": { - "notifications": { - "url": "https://isa-test.paragon-data.net/isa/v1/rt", - "enableAutomaticReconnect": false, - "httpOptions": { - "transport": 1, - "logMessageContent": true, - "skipNegotiation": true - } - } - }, - "process": { - "ids": { - "goodsOut": 1000, - "goodsIn": 2000, - "taskCalendar": 3000, - "remission": 4000, - "packageInspection": 5000, - "assortment": 6000, - "pickupShelf": 7000 - } - }, - "checkForUpdates": 3600000, - "licence": { - "scandit": "Ae8F2Wx2RMq5Lvn7UUAlWzVFZTt2+ubMAF8XtDpmPlNkBeG/LWs1M7AbgDW0LQqYLnszClEENaEHS56/6Ts2vrJ1Ux03CXUjK3jUvZpF5OchXR1CpnmpepJ6WxPCd7LMVHUGG1BbwPLDTFjP3y8uT0caTSmmGrYQWAs4CZcEF+ZBabP0z7vfm+hCZF/ebj9qqCJZcW8nH/n19hohshllzYBjFXjh87P2lIh1s6yZS3OaQWWXo/o0AKdxx7T6CVyR0/G5zq6uYJWf6rs3euUBEhpzOZHbHZK86Lvy2AVBEyVkkcttlDW1J2fA4l1W1JV/Xibz8AQV6kG482EpGF42KEoK48paZgX3e1AQsqUtmqzw294dcP4zMVstnw5/WrwKKi/5E/nOOJT2txYP1ZufIjPrwNFsqTlv7xCQlHjMzFGYwT816yD5qLRLbwOtjrkUPXNZLZ06T4upvWwJDmm8XgdeoDqMjHdcO4lwji1bl9EiIYJ/2qnsk9yZ2FqSaHzn4cbiL0f5u2HFlNAP0GUujGRlthGhHi6o4dFU+WAxKsFMKVt+SfoQUazNKHFVQgiAklTIZxIc/HUVzRvOLMxf+wFDerraBtcqGJg+g/5mrWYqeDBGhCBHtKiYf6244IJ4afzNTiH1/30SJcRzXwbEa3A7q1fJTx9/nLTOfVPrJKBQs7f/OQs2dA7LDCel8mzXdbjvsNQaeU5+iCIAq6zbTNKy1xT8wwj+VZrQmtNJs+qeznD+u29nCM24h8xCmRpvNPo4/Mww/lrTNrrNwLBSn1pMIwsH7yS9hH0v0oNAM3A6bVtk1D9qEkbyw+xZa+MZGpMP0D0CdcsqHalPcm5r/Ik=" - }, - "gender": { - "0": "Keine Anrede", - "1": "Enby", - "2": "Herr", - "4": "Frau" - }, - "@shared/icon": "/assets/icons.json" -} +{ + "title": "ISA - Local", + "silentRefresh": { + "interval": 300000 + }, + "debug": true, + "dev-scanner": true, + "@cdn/product-image": { + "url": "https://produktbilder.paragon-data.net" + }, + "@core/auth": { + "issuer": "https://sso-test.paragon-data.de", + "clientId": "isa-client", + "responseType": "code", + "scope": "openid profile cmf_user isa-isa-webapi isa-checkout-webapi isa-cat-webapi isa-ava-webapi isa-crm-webapi isa-review-webapi isa-kpi-webapi isa-oms-webapi isa-nbo-webapi isa-print-webapi eis-service isa-inv-webapi isa-wws-webapi", + "showDebugInformation": true + }, + "@core/logger": { + "logLevel": "debug" + }, + "@swagger/isa": { + "rootUrl": "https://isa-test.paragon-data.net/isa/v1" + }, + "@swagger/cat": { + "rootUrl": "https://isa-test.paragon-data.net/catsearch/v6" + }, + "@swagger/av": { + "rootUrl": "https://isa-test.paragon-data.net/ava/v6" + }, + "@swagger/checkout": { + "rootUrl": "https://isa-test.paragon-data.net/checkout/v6" + }, + "@swagger/crm": { + "rootUrl": "https://isa-test.paragon-data.net/crm/v6" + }, + "@swagger/oms": { + "rootUrl": "https://isa-test.paragon-data.net/oms/v6" + }, + "@swagger/print": { + "rootUrl": "https://isa-test.paragon-data.net/print/v1" + }, + "@swagger/eis": { + "rootUrl": "https://filialinformationsystem-test.paragon-systems.de/eiswebapi/v1" + }, + "@swagger/remi": { + "rootUrl": "https://isa-test.paragon-data.net/inv/v6" + }, + "@swagger/wws": { + "rootUrl": "https://isa-test.paragon-data.net/wws/v1" + }, + "@domain/checkout": { + "olaExpiration": "5m" + }, + "hubs": { + "notifications": { + "url": "https://isa-test.paragon-data.net/isa/v1/rt", + "enableAutomaticReconnect": false, + "httpOptions": { + "transport": 1, + "logMessageContent": true, + "skipNegotiation": true + } + } + }, + "process": { + "ids": { + "goodsOut": 1000, + "goodsIn": 2000, + "taskCalendar": 3000, + "packageInspection": 5000, + "assortment": 6000, + "pickupShelf": 2000 + } + }, + "checkForUpdates": 3600000, + "licence": { + "scandit": "Ae8F2Wx2RMq5Lvn7UUAlWzVFZTt2+ubMAF8XtDpmPlNkBeG/LWs1M7AbgDW0LQqYLnszClEENaEHS56/6Ts2vrJ1Ux03CXUjK3jUvZpF5OchXR1CpnmpepJ6WxPCd7LMVHUGG1BbwPLDTFjP3y8uT0caTSmmGrYQWAs4CZcEF+ZBabP0z7vfm+hCZF/ebj9qqCJZcW8nH/n19hohshllzYBjFXjh87P2lIh1s6yZS3OaQWWXo/o0AKdxx7T6CVyR0/G5zq6uYJWf6rs3euUBEhpzOZHbHZK86Lvy2AVBEyVkkcttlDW1J2fA4l1W1JV/Xibz8AQV6kG482EpGF42KEoK48paZgX3e1AQsqUtmqzw294dcP4zMVstnw5/WrwKKi/5E/nOOJT2txYP1ZufIjPrwNFsqTlv7xCQlHjMzFGYwT816yD5qLRLbwOtjrkUPXNZLZ06T4upvWwJDmm8XgdeoDqMjHdcO4lwji1bl9EiIYJ/2qnsk9yZ2FqSaHzn4cbiL0f5u2HFlNAP0GUujGRlthGhHi6o4dFU+WAxKsFMKVt+SfoQUazNKHFVQgiAklTIZxIc/HUVzRvOLMxf+wFDerraBtcqGJg+g/5mrWYqeDBGhCBHtKiYf6244IJ4afzNTiH1/30SJcRzXwbEa3A7q1fJTx9/nLTOfVPrJKBQs7f/OQs2dA7LDCel8mzXdbjvsNQaeU5+iCIAq6zbTNKy1xT8wwj+VZrQmtNJs+qeznD+u29nCM24h8xCmRpvNPo4/Mww/lrTNrrNwLBSn1pMIwsH7yS9hH0v0oNAM3A6bVtk1D9qEkbyw+xZa+MZGpMP0D0CdcsqHalPcm5r/Ik=" + }, + "gender": { + "0": "Keine Anrede", + "1": "Enby", + "2": "Herr", + "4": "Frau" + }, + "@shared/icon": "/assets/icons.json" +} diff --git a/apps/isa-app/src/core/application/application.service.ts b/apps/isa-app/src/core/application/application.service.ts index e36958608..07bc16b26 100644 --- a/apps/isa-app/src/core/application/application.service.ts +++ b/apps/isa-app/src/core/application/application.service.ts @@ -1,174 +1,196 @@ -import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { BranchDTO } from '@generated/swagger/checkout-api'; -import { isBoolean, isNumber } from '@utils/common'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { first, map, switchMap } from 'rxjs/operators'; -import { ApplicationProcess } from './defs'; -import { - removeProcess, - selectSection, - selectProcesses, - setSection, - addProcess, - setActivatedProcess, - selectActivatedProcess, - patchProcess, - patchProcessData, - selectTitle, - setTitle, -} from './store'; - -@Injectable() -export class ApplicationService { - private activatedProcessIdSubject = new BehaviorSubject(undefined); - - get activatedProcessId() { - return this.activatedProcessIdSubject.value; - } - - get activatedProcessId$() { - return this.activatedProcessIdSubject.asObservable(); - } - - constructor(private store: Store) {} - - getProcesses$(section?: 'customer' | 'branch') { - const processes$ = this.store.select(selectProcesses); - return processes$.pipe( - map((processes) => processes.filter((process) => (section ? process.section === section : true))), - ); - } - - getProcessById$(processId: number): Observable { - return this.getProcesses$().pipe(map((processes) => processes.find((process) => process.id === processId))); - } - - getSection$() { - return this.store.select(selectSection); - } - - getTitle$() { - return this.getSection$().pipe( - map((section) => { - return section === 'customer' ? 'Kundenbereich' : 'Filialbereich'; - }), - ); - } - - /** @deprecated */ - getActivatedProcessId$() { - return this.store.select(selectActivatedProcess).pipe(map((process) => process?.id)); - } - - activateProcess(activatedProcessId: number) { - this.store.dispatch(setActivatedProcess({ activatedProcessId })); - this.activatedProcessIdSubject.next(activatedProcessId); - } - - removeProcess(processId: number) { - this.store.dispatch(removeProcess({ processId })); - } - - patchProcess(processId: number, changes: Partial) { - this.store.dispatch(patchProcess({ processId, changes })); - } - - patchProcessData(processId: number, data: Record) { - this.store.dispatch(patchProcessData({ processId, data })); - } - - getSelectedBranch$(processId?: number): Observable { - if (!processId) { - return this.activatedProcessId$.pipe( - switchMap((processId) => this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch))), - ); - } - - return this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch)); - } - - readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/; - - async createCustomerProcess(processId?: number): Promise { - const processes = await this.getProcesses$('customer').pipe(first()).toPromise(); - - const processIds = processes.filter((x) => this.REGEX_PROCESS_NAME.test(x.name)).map((x) => +x.name.split(' ')[1]); - - const maxId = processIds.length > 0 ? Math.max(...processIds) : 0; - - const process: ApplicationProcess = { - id: processId ?? Date.now(), - type: 'cart', - name: `Vorgang ${maxId + 1}`, - section: 'customer', - closeable: true, - }; - - await this.createProcess(process); - - return process; - } - - async createProcess(process: ApplicationProcess) { - const existingProcess = await this.getProcessById$(process?.id).pipe(first()).toPromise(); - if (existingProcess?.id === process?.id) { - throw new Error('Process Id existiert bereits'); - } - - if (!isNumber(process.id)) { - throw new Error('Process Id nicht gesetzt'); - } - - if (!isBoolean(process.closeable)) { - process.closeable = true; - } - - if (!isBoolean(process.confirmClosing)) { - process.confirmClosing = true; - } - - process.created = this._createTimestamp(); - process.activated = 0; - this.store.dispatch(addProcess({ process })); - } - - setSection(section: 'customer' | 'branch') { - this.store.dispatch(setSection({ section })); - } - - getLastActivatedProcessWithSectionAndType$( - section: 'customer' | 'branch', - type: string, - ): Observable { - return this.getProcesses$(section).pipe( - map((processes) => - processes - ?.filter((process) => process.type === type) - ?.reduce((latest, current) => { - if (!latest) { - return current; - } - return latest?.activated > current?.activated ? latest : current; - }, undefined), - ), - ); - } - - getLastActivatedProcessWithSection$(section: 'customer' | 'branch'): Observable { - return this.getProcesses$(section).pipe( - map((processes) => - processes?.reduce((latest, current) => { - if (!latest) { - return current; - } - return latest?.activated > current?.activated ? latest : current; - }, undefined), - ), - ); - } - - private _createTimestamp() { - return Date.now(); - } -} +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { BranchDTO } from '@generated/swagger/checkout-api'; +import { isBoolean, isNumber } from '@utils/common'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { first, map, switchMap } from 'rxjs/operators'; +import { ApplicationProcess } from './defs'; +import { + removeProcess, + selectSection, + selectProcesses, + setSection, + addProcess, + setActivatedProcess, + selectActivatedProcess, + patchProcess, + patchProcessData, + selectTitle, + setTitle, +} from './store'; + +@Injectable() +export class ApplicationService { + private activatedProcessIdSubject = new BehaviorSubject(undefined); + + get activatedProcessId() { + return this.activatedProcessIdSubject.value; + } + + get activatedProcessId$() { + return this.activatedProcessIdSubject.asObservable(); + } + + constructor(private store: Store) {} + + getProcesses$(section?: 'customer' | 'branch') { + const processes$ = this.store.select(selectProcesses); + return processes$.pipe( + map((processes) => + processes.filter((process) => + section ? process.section === section : true, + ), + ), + ); + } + + getProcessById$(processId: number): Observable { + return this.getProcesses$().pipe( + map((processes) => processes.find((process) => process.id === processId)), + ); + } + + getSection$() { + return this.store.select(selectSection); + } + + getTitle$() { + return this.getSection$().pipe( + map((section) => { + return section === 'customer' ? 'Kundenbereich' : 'Filialbereich'; + }), + ); + } + + /** @deprecated */ + getActivatedProcessId$() { + return this.store + .select(selectActivatedProcess) + .pipe(map((process) => process?.id)); + } + + activateProcess(activatedProcessId: number) { + this.store.dispatch(setActivatedProcess({ activatedProcessId })); + this.activatedProcessIdSubject.next(activatedProcessId); + } + + removeProcess(processId: number) { + this.store.dispatch(removeProcess({ processId })); + } + + patchProcess(processId: number, changes: Partial) { + this.store.dispatch(patchProcess({ processId, changes })); + } + + patchProcessData(processId: number, data: Record) { + this.store.dispatch(patchProcessData({ processId, data })); + } + + getSelectedBranch$(processId?: number): Observable { + if (!processId) { + return this.activatedProcessId$.pipe( + switchMap((processId) => + this.getProcessById$(processId).pipe( + map((process) => process?.data?.selectedBranch), + ), + ), + ); + } + + return this.getProcessById$(processId).pipe( + map((process) => process?.data?.selectedBranch), + ); + } + + readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/; + + async createCustomerProcess(processId?: number): Promise { + const processes = await this.getProcesses$('customer') + .pipe(first()) + .toPromise(); + + const processIds = processes + .filter((x) => this.REGEX_PROCESS_NAME.test(x.name)) + .map((x) => +x.name.split(' ')[1]); + + const maxId = processIds.length > 0 ? Math.max(...processIds) : 0; + + const process: ApplicationProcess = { + id: processId ?? Date.now(), + type: 'cart', + name: `Vorgang ${maxId + 1}`, + section: 'customer', + closeable: true, + }; + + await this.createProcess(process); + + return process; + } + + async createProcess(process: ApplicationProcess) { + const existingProcess = await this.getProcessById$(process?.id) + .pipe(first()) + .toPromise(); + if (existingProcess?.id === process?.id) { + throw new Error('Process Id existiert bereits'); + } + + if (!isNumber(process.id)) { + throw new Error('Process Id nicht gesetzt'); + } + + if (!isBoolean(process.closeable)) { + process.closeable = true; + } + + if (!isBoolean(process.confirmClosing)) { + process.confirmClosing = true; + } + + process.created = this._createTimestamp(); + process.activated = 0; + this.store.dispatch(addProcess({ process })); + } + + setSection(section: 'customer' | 'branch') { + this.store.dispatch(setSection({ section })); + } + + getLastActivatedProcessWithSectionAndType$( + section: 'customer' | 'branch', + type: string, + ): Observable { + return this.getProcesses$(section).pipe( + map((processes) => + processes + ?.filter((process) => process.type === type) + ?.reduce((latest, current) => { + if (!latest) { + return current; + } + return latest?.activated > current?.activated ? latest : current; + }, undefined), + ), + ); + } + + getLastActivatedProcessWithSection$( + section: 'customer' | 'branch', + ): Observable { + return this.getProcesses$(section).pipe( + map((processes) => + processes?.reduce((latest, current) => { + if (!latest) { + return current; + } + return latest?.activated > current?.activated ? latest : current; + }, undefined), + ), + ); + } + + private _createTimestamp() { + return Date.now(); + } +} diff --git a/apps/isa-app/src/domain/checkout/store/domain-checkout.reducer.ts b/apps/isa-app/src/domain/checkout/store/domain-checkout.reducer.ts index 7b0a027b1..4f9ff411a 100644 --- a/apps/isa-app/src/domain/checkout/store/domain-checkout.reducer.ts +++ b/apps/isa-app/src/domain/checkout/store/domain-checkout.reducer.ts @@ -35,7 +35,7 @@ const _domainCheckoutReducer = createReducer( const now = Date.now(); - for (let shoppingCartItem of addedShoppingCartItems) { + for (const shoppingCartItem of addedShoppingCartItems) { if (shoppingCartItem.features?.orderType) { entity.itemAvailabilityTimestamp[ `${shoppingCartItem.id}_${shoppingCartItem.features.orderType}` @@ -275,7 +275,7 @@ function getOrCreateCheckoutEntity({ entities: Dictionary; processId: number; }): CheckoutEntity { - let entity = entities[processId]; + const entity = entities[processId]; if (isNullOrUndefined(entity)) { return { diff --git a/apps/isa-app/src/page/assortment/routes.ts b/apps/isa-app/src/page/assortment/routes.ts index b2660d1c1..c7fe29076 100644 --- a/apps/isa-app/src/page/assortment/routes.ts +++ b/apps/isa-app/src/page/assortment/routes.ts @@ -1,14 +1,19 @@ -import { Routes } from '@angular/router'; -import { AssortmentComponent } from './assortment.component'; -import { PriceUpdateComponent } from './price-update/price-update.component'; - -export const routes: Routes = [ - { - path: '', - component: AssortmentComponent, - children: [ - { path: 'price-update', component: PriceUpdateComponent }, - { path: '**', redirectTo: 'price-update' }, - ], - }, -]; +import { Routes } from '@angular/router'; +import { AssortmentComponent } from './assortment.component'; +import { PriceUpdateComponent } from './price-update/price-update.component'; + +export const routes: Routes = [ + { + path: '', + component: AssortmentComponent, + title: 'Sortiment', + children: [ + { + path: 'price-update', + component: PriceUpdateComponent, + title: 'Sortiment - Preisänderung', + }, + { path: '**', redirectTo: 'price-update' }, + ], + }, +]; diff --git a/apps/isa-app/src/page/catalog/page-catalog-routing.module.ts b/apps/isa-app/src/page/catalog/page-catalog-routing.module.ts index 5d796fd6c..be9e8e03c 100644 --- a/apps/isa-app/src/page/catalog/page-catalog-routing.module.ts +++ b/apps/isa-app/src/page/catalog/page-catalog-routing.module.ts @@ -1,125 +1,135 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; -import { ArticleDetailsComponent } from './article-details/article-details.component'; -import { ArticleSearchComponent } from './article-search/article-search.component'; -import { ArticleSearchFilterComponent } from './article-search/search-filter/search-filter.component'; -import { ArticleSearchMainComponent } from './article-search/search-main/search-main.component'; -import { ArticleSearchResultsComponent } from './article-search/search-results/search-results.component'; -import { PageCatalogComponent } from './page-catalog.component'; -import { MatomoRouteData } from 'ngx-matomo-client'; - -const routes: Routes = [ - { - path: '', - component: PageCatalogComponent, - data: { - matomo: { - title: 'Artikelsuche', - } as MatomoRouteData, - }, - children: [ - { - path: 'filter', - component: ArticleSearchFilterComponent, - data: { - matomo: { - title: 'Artikelsuche - Filter', - } as MatomoRouteData, - }, - }, - { - path: 'filter/:id', - component: ArticleSearchFilterComponent, - data: { - matomo: { - title: 'Artikelsuche - Filter', - } as MatomoRouteData, - }, - }, - { - path: 'search', - component: ArticleSearchComponent, - outlet: 'side', - data: { - matomo: { - title: 'Artikelsuche', - } as MatomoRouteData, - }, - children: [ - { - path: '', - component: ArticleSearchMainComponent, - }, - ], - }, - { - path: 'results', - component: ArticleSearchResultsComponent, - data: { - matomo: { - title: 'Artikelsuche - Trefferliste', - } as MatomoRouteData, - }, - }, - { - path: 'results', - component: ArticleSearchResultsComponent, - outlet: 'side', - data: { - matomo: { - title: 'Artikelsuche - Trefferliste', - } as MatomoRouteData, - }, - }, - { - path: 'results/:id', - component: ArticleSearchResultsComponent, - outlet: 'side', - data: { - matomo: { - title: 'Artikelsuche - Artikeldetails', - } as MatomoRouteData, - }, - }, - { - path: 'results/:ean/ean', - component: ArticleSearchResultsComponent, - outlet: 'side', - data: { - matomo: { - title: 'Artikelsuche - Artikeldetails (EAN)', - } as MatomoRouteData, - }, - }, - { - path: 'details/:id', - component: ArticleDetailsComponent, - data: { - matomo: { - title: 'Artikelsuche - Artikeldetails (ID)', - } as MatomoRouteData, - }, - }, - { - path: 'details/:ean/ean', - component: ArticleDetailsComponent, - data: { - matomo: { - title: 'Artikelsuche - Artikeldetails (EAN)', - } as MatomoRouteData, - }, - }, - { - path: '', - pathMatch: 'full', - redirectTo: 'filter', - }, - ], - }, -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule], -}) -export class PageCatalogRoutingModule {} +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { ArticleDetailsComponent } from './article-details/article-details.component'; +import { ArticleSearchComponent } from './article-search/article-search.component'; +import { ArticleSearchFilterComponent } from './article-search/search-filter/search-filter.component'; +import { ArticleSearchMainComponent } from './article-search/search-main/search-main.component'; +import { ArticleSearchResultsComponent } from './article-search/search-results/search-results.component'; +import { PageCatalogComponent } from './page-catalog.component'; +import { MatomoRouteData } from 'ngx-matomo-client'; + +const routes: Routes = [ + { + path: '', + component: PageCatalogComponent, + title: 'Artikelsuche', + data: { + matomo: { + title: 'Artikelsuche', + } as MatomoRouteData, + }, + children: [ + { + path: 'filter', + component: ArticleSearchFilterComponent, + title: 'Artikelsuche - Filter', + data: { + matomo: { + title: 'Artikelsuche - Filter', + } as MatomoRouteData, + }, + }, + { + path: 'filter/:id', + component: ArticleSearchFilterComponent, + title: 'Artikelsuche - Filter', + data: { + matomo: { + title: 'Artikelsuche - Filter', + } as MatomoRouteData, + }, + }, + { + path: 'search', + component: ArticleSearchComponent, + outlet: 'side', + title: 'Artikelsuche', + data: { + matomo: { + title: 'Artikelsuche', + } as MatomoRouteData, + }, + children: [ + { + path: '', + component: ArticleSearchMainComponent, + }, + ], + }, + { + path: 'results', + component: ArticleSearchResultsComponent, + title: 'Artikelsuche - Trefferliste', + data: { + matomo: { + title: 'Artikelsuche - Trefferliste', + } as MatomoRouteData, + }, + }, + { + path: 'results', + component: ArticleSearchResultsComponent, + outlet: 'side', + title: 'Artikelsuche - Trefferliste', + data: { + matomo: { + title: 'Artikelsuche - Trefferliste', + } as MatomoRouteData, + }, + }, + { + path: 'results/:id', + component: ArticleSearchResultsComponent, + outlet: 'side', + title: 'Artikelsuche - Artikeldetails', + data: { + matomo: { + title: 'Artikelsuche - Artikeldetails', + } as MatomoRouteData, + }, + }, + { + path: 'results/:ean/ean', + component: ArticleSearchResultsComponent, + outlet: 'side', + title: 'Artikelsuche - Artikeldetails (EAN)', + data: { + matomo: { + title: 'Artikelsuche - Artikeldetails (EAN)', + } as MatomoRouteData, + }, + }, + { + path: 'details/:id', + component: ArticleDetailsComponent, + title: 'Artikelsuche - Artikeldetails (ID)', + data: { + matomo: { + title: 'Artikelsuche - Artikeldetails (ID)', + } as MatomoRouteData, + }, + }, + { + path: 'details/:ean/ean', + component: ArticleDetailsComponent, + title: 'Artikelsuche - Artikeldetails (EAN)', + data: { + matomo: { + title: 'Artikelsuche - Artikeldetails (EAN)', + } as MatomoRouteData, + }, + }, + { + path: '', + pathMatch: 'full', + redirectTo: 'filter', + }, + ], + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class PageCatalogRoutingModule {} diff --git a/apps/isa-app/src/page/checkout/page-checkout-routing.module.ts b/apps/isa-app/src/page/checkout/page-checkout-routing.module.ts index 041b761b9..2dc6b2e30 100644 --- a/apps/isa-app/src/page/checkout/page-checkout-routing.module.ts +++ b/apps/isa-app/src/page/checkout/page-checkout-routing.module.ts @@ -14,20 +14,24 @@ const routes: Routes = [ { path: 'details', component: CheckoutReviewDetailsComponent, + title: 'Bestelldetails', outlet: 'side', }, { path: 'review', component: CheckoutReviewComponent, + title: 'Bestellung überprüfen', }, { path: 'summary', component: CheckoutSummaryComponent, + title: 'Bestellübersicht', canDeactivate: [canDeactivateTabCleanup], }, { path: 'summary/:orderIds', component: CheckoutSummaryComponent, + title: 'Bestellübersicht', canDeactivate: [canDeactivateTabCleanup], }, { path: '', pathMatch: 'full', redirectTo: 'review' }, diff --git a/apps/isa-app/src/page/customer/routes.ts b/apps/isa-app/src/page/customer/routes.ts index 68ff545e5..61f96e5ed 100644 --- a/apps/isa-app/src/page/customer/routes.ts +++ b/apps/isa-app/src/page/customer/routes.ts @@ -1,170 +1,187 @@ -import { Routes } from '@angular/router'; -import { CustomerComponent } from './customer-page.component'; -import { CustomerSearchComponent } from './customer-search/customer-search.component'; -import { CustomerResultsMainViewComponent } from './customer-search/results-main-view/results-main-view.component'; -import { CustomerDetailsViewMainComponent } from './customer-search/details-main-view/details-main-view.component'; -import { CustomerHistoryMainViewComponent } from './customer-search/history-main-view/history-main-view.component'; -import { CustomerFilterMainViewComponent } from './customer-search/filter-main-view/filter-main-view.component'; -import { CustomerCreateGuard } from './guards/customer-create.guard'; -import { - CreateB2BCustomerComponent, - CreateGuestCustomerComponent, - // CreateP4MCustomerComponent, - CreateStoreCustomerComponent, - CreateWebshopCustomerComponent, -} from './create-customer'; -// import { UpdateP4MWebshopCustomerComponent } from './create-customer/update-p4m-webshop-customer'; -import { CreateCustomerComponent } from './create-customer/create-customer.component'; -import { CustomerDataEditB2BComponent } from './customer-search/edit-main-view/customer-data-edit-b2b.component'; -import { CustomerDataEditB2CComponent } from './customer-search/edit-main-view/customer-data-edit-b2c.component'; -import { AddBillingAddressMainViewComponent } from './customer-search/add-billing-address-main-view/add-billing-address-main-view.component'; -import { AddShippingAddressMainViewComponent } from './customer-search/add-shipping-address-main-view/add-shipping-address-main-view.component'; -import { EditBillingAddressMainViewComponent } from './customer-search/edit-billing-address-main-view/edit-billing-address-main-view.component'; -import { EditShippingAddressMainViewComponent } from './customer-search/edit-shipping-address-main-view/edit-shipping-address-main-view.component'; -import { CustomerOrdersMainViewComponent } from './customer-search/orders-main-view/orders-main-view.component'; -import { OrderDetailsMainViewComponent } from './customer-search/order-details-main-view/order-details-main-view.component'; -import { KundenkarteMainViewComponent } from './customer-search/kundenkarte-main-view/kundenkarte-main-view.component'; -import { CustomerOrderDetailsHistoryMainViewComponent } from './customer-search/order-details-history-main-view/order-details-history-main-view.component'; -import { CustomerMainViewComponent } from './customer-search/main-view/main-view.component'; -import { MainSideViewComponent } from './customer-search/main-side-view/main-side-view.component'; -import { CustomerResultsSideViewComponent } from './customer-search/results-side-view/results-side-view.component'; -import { OrderDetailsSideViewComponent } from './customer-search/order-details-side-view/order-details-side-view.component'; -import { CustomerCreateSideViewComponent } from './create-customer/customer-create-side-view'; - -export const routes: Routes = [ - { - path: '', - component: CustomerComponent, - children: [ - { - path: '', - component: CustomerSearchComponent, - children: [ - { - path: 'search', - component: CustomerMainViewComponent, - data: { side: 'main', breadcrumb: 'main' }, - }, - { - path: 'search/list', - component: CustomerResultsMainViewComponent, - data: { breadcrumb: 'search' }, - }, - { - path: 'search/filter', - component: CustomerFilterMainViewComponent, - data: { side: 'results', breadcrumb: 'filter' }, - }, - { - path: 'search/:customerId', - component: CustomerDetailsViewMainComponent, - data: { side: 'results', breadcrumb: 'details' }, - }, - { - path: 'search/:customerId/history', - component: CustomerHistoryMainViewComponent, - data: { side: 'results', breadcrumb: 'history' }, - }, - { - path: 'search/:customerId/kundenkarte', - component: KundenkarteMainViewComponent, - data: { side: 'results', breadcrumb: 'kundenkarte' }, - }, - { - path: 'search/:customerId/orders', - component: CustomerOrdersMainViewComponent, - data: { side: 'results', breadcrumb: 'orders' }, - }, - { - path: 'search/:customerId/orders/:orderId', - component: OrderDetailsMainViewComponent, - data: { side: 'order-details', breadcrumb: 'order-details' }, - }, - { - path: 'search/:customerId/orders/:orderId/:orderItemId', - component: OrderDetailsMainViewComponent, - data: { side: 'order-details', breadcrumb: 'order-details' }, - }, - { - path: 'search/:customerId/orders/:orderId/:orderItemId/history', - component: CustomerOrderDetailsHistoryMainViewComponent, - data: { - side: 'order-details', - breadcrumb: 'order-details-history', - }, - }, - { - path: 'search/:customerId/edit/b2b', - component: CustomerDataEditB2BComponent, - data: { side: 'results', breadcrumb: 'edit' }, - }, - { - path: 'search/:customerId/edit', - component: CustomerDataEditB2CComponent, - data: { side: 'results', breadcrumb: 'edit' }, - }, - { - path: 'search/:customerId/billingaddress/add', - component: AddBillingAddressMainViewComponent, - data: { side: 'results', breadcrumb: 'add-billing-address' }, - }, - { - path: 'search/:customerId/billingaddress/:payerId/edit', - component: EditBillingAddressMainViewComponent, - data: { side: 'results', breadcrumb: 'edit-billing-address' }, - }, - { - path: 'search/:customerId/shippingaddress/add', - component: AddShippingAddressMainViewComponent, - data: { side: 'results', breadcrumb: 'add-shipping-address' }, - }, - { - path: 'search/:customerId/shippingaddress/:shippingAddressId/edit', - component: EditShippingAddressMainViewComponent, - data: { side: 'results', breadcrumb: 'edit-shipping-address' }, - }, - { - path: 'search-customer-main', - outlet: 'side', - component: MainSideViewComponent, - }, - { - path: 'results', - outlet: 'side', - component: CustomerResultsSideViewComponent, - }, - { - path: 'order-details', - outlet: 'side', - component: OrderDetailsSideViewComponent, - }, - ], - }, - { - path: '', - component: CreateCustomerComponent, - canActivate: [CustomerCreateGuard], - canActivateChild: [CustomerCreateGuard], - children: [ - { path: 'create', component: CreateStoreCustomerComponent }, - { path: 'create/store', component: CreateStoreCustomerComponent }, - { path: 'create/webshop', component: CreateWebshopCustomerComponent }, - { path: 'create/b2b', component: CreateB2BCustomerComponent }, - { path: 'create/guest', component: CreateGuestCustomerComponent }, - // { path: 'create/webshop-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'webshop' } }, - // { path: 'create/store-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'store' } }, - // { - // path: 'create/webshop-p4m/update', - // component: UpdateP4MWebshopCustomerComponent, - // data: { customerType: 'webshop' }, - // }, - { - path: 'create-customer-main', - outlet: 'side', - component: CustomerCreateSideViewComponent, - }, - ], - }, - ], - }, -]; +import { Routes } from '@angular/router'; +import { CustomerComponent } from './customer-page.component'; +import { CustomerSearchComponent } from './customer-search/customer-search.component'; +import { CustomerResultsMainViewComponent } from './customer-search/results-main-view/results-main-view.component'; +import { CustomerDetailsViewMainComponent } from './customer-search/details-main-view/details-main-view.component'; +import { CustomerHistoryMainViewComponent } from './customer-search/history-main-view/history-main-view.component'; +import { CustomerFilterMainViewComponent } from './customer-search/filter-main-view/filter-main-view.component'; +import { CustomerCreateGuard } from './guards/customer-create.guard'; +import { + CreateB2BCustomerComponent, + CreateGuestCustomerComponent, + // CreateP4MCustomerComponent, + CreateStoreCustomerComponent, + CreateWebshopCustomerComponent, +} from './create-customer'; +// import { UpdateP4MWebshopCustomerComponent } from './create-customer/update-p4m-webshop-customer'; +import { CreateCustomerComponent } from './create-customer/create-customer.component'; +import { CustomerDataEditB2BComponent } from './customer-search/edit-main-view/customer-data-edit-b2b.component'; +import { CustomerDataEditB2CComponent } from './customer-search/edit-main-view/customer-data-edit-b2c.component'; +import { AddBillingAddressMainViewComponent } from './customer-search/add-billing-address-main-view/add-billing-address-main-view.component'; +import { AddShippingAddressMainViewComponent } from './customer-search/add-shipping-address-main-view/add-shipping-address-main-view.component'; +import { EditBillingAddressMainViewComponent } from './customer-search/edit-billing-address-main-view/edit-billing-address-main-view.component'; +import { EditShippingAddressMainViewComponent } from './customer-search/edit-shipping-address-main-view/edit-shipping-address-main-view.component'; +import { CustomerOrdersMainViewComponent } from './customer-search/orders-main-view/orders-main-view.component'; +import { OrderDetailsMainViewComponent } from './customer-search/order-details-main-view/order-details-main-view.component'; +import { KundenkarteMainViewComponent } from './customer-search/kundenkarte-main-view/kundenkarte-main-view.component'; +import { CustomerOrderDetailsHistoryMainViewComponent } from './customer-search/order-details-history-main-view/order-details-history-main-view.component'; +import { CustomerMainViewComponent } from './customer-search/main-view/main-view.component'; +import { MainSideViewComponent } from './customer-search/main-side-view/main-side-view.component'; +import { CustomerResultsSideViewComponent } from './customer-search/results-side-view/results-side-view.component'; +import { OrderDetailsSideViewComponent } from './customer-search/order-details-side-view/order-details-side-view.component'; +import { CustomerCreateSideViewComponent } from './create-customer/customer-create-side-view'; + +export const routes: Routes = [ + { + path: '', + component: CustomerComponent, + children: [ + { + path: '', + component: CustomerSearchComponent, + children: [ + { + path: 'search', + component: CustomerMainViewComponent, + title: 'Kundensuche', + data: { side: 'main', breadcrumb: 'main' }, + }, + { + path: 'search/list', + component: CustomerResultsMainViewComponent, + title: 'Kundensuche - Trefferliste', + data: { breadcrumb: 'search' }, + }, + { + path: 'search/filter', + component: CustomerFilterMainViewComponent, + title: 'Kundensuche - Filter', + data: { side: 'results', breadcrumb: 'filter' }, + }, + { + path: 'search/:customerId', + component: CustomerDetailsViewMainComponent, + title: 'Kundendetails', + data: { side: 'results', breadcrumb: 'details' }, + }, + { + path: 'search/:customerId/history', + component: CustomerHistoryMainViewComponent, + title: 'Kundendetails - Verlauf', + data: { side: 'results', breadcrumb: 'history' }, + }, + { + path: 'search/:customerId/kundenkarte', + component: KundenkarteMainViewComponent, + title: 'Kundendetails - Kundenkarte', + data: { side: 'results', breadcrumb: 'kundenkarte' }, + }, + { + path: 'search/:customerId/orders', + component: CustomerOrdersMainViewComponent, + title: 'Kundendetails - Bestellungen', + data: { side: 'results', breadcrumb: 'orders' }, + }, + { + path: 'search/:customerId/orders/:orderId', + component: OrderDetailsMainViewComponent, + title: 'Kundendetails - Bestelldetails', + data: { side: 'order-details', breadcrumb: 'order-details' }, + }, + { + path: 'search/:customerId/orders/:orderId/:orderItemId', + component: OrderDetailsMainViewComponent, + title: 'Kundendetails - Bestelldetails', + data: { side: 'order-details', breadcrumb: 'order-details' }, + }, + { + path: 'search/:customerId/orders/:orderId/:orderItemId/history', + component: CustomerOrderDetailsHistoryMainViewComponent, + title: 'Kundendetails - Bestelldetails Verlauf', + data: { + side: 'order-details', + breadcrumb: 'order-details-history', + }, + }, + { + path: 'search/:customerId/edit/b2b', + component: CustomerDataEditB2BComponent, + title: 'Kundendetails - Bearbeiten (B2B)', + data: { side: 'results', breadcrumb: 'edit' }, + }, + { + path: 'search/:customerId/edit', + component: CustomerDataEditB2CComponent, + title: 'Kundendetails - Bearbeiten', + data: { side: 'results', breadcrumb: 'edit' }, + }, + { + path: 'search/:customerId/billingaddress/add', + component: AddBillingAddressMainViewComponent, + title: 'Kundendetails - Neue Rechnungsadresse', + data: { side: 'results', breadcrumb: 'add-billing-address' }, + }, + { + path: 'search/:customerId/billingaddress/:payerId/edit', + component: EditBillingAddressMainViewComponent, + title: 'Kundendetails - Rechnungsadresse bearbeiten', + data: { side: 'results', breadcrumb: 'edit-billing-address' }, + }, + { + path: 'search/:customerId/shippingaddress/add', + component: AddShippingAddressMainViewComponent, + title: 'Kundendetails - Neue Lieferadresse', + data: { side: 'results', breadcrumb: 'add-shipping-address' }, + }, + { + path: 'search/:customerId/shippingaddress/:shippingAddressId/edit', + component: EditShippingAddressMainViewComponent, + title: 'Kundendetails - Lieferadresse bearbeiten', + data: { side: 'results', breadcrumb: 'edit-shipping-address' }, + }, + { + path: 'search-customer-main', + outlet: 'side', + component: MainSideViewComponent, + }, + { + path: 'results', + outlet: 'side', + component: CustomerResultsSideViewComponent, + }, + { + path: 'order-details', + outlet: 'side', + component: OrderDetailsSideViewComponent, + }, + ], + }, + { + path: '', + component: CreateCustomerComponent, + canActivate: [CustomerCreateGuard], + canActivateChild: [CustomerCreateGuard], + title: 'Kundendaten erfassen', + children: [ + { path: 'create', component: CreateStoreCustomerComponent }, + { path: 'create/store', component: CreateStoreCustomerComponent }, + { path: 'create/webshop', component: CreateWebshopCustomerComponent }, + { path: 'create/b2b', component: CreateB2BCustomerComponent }, + { path: 'create/guest', component: CreateGuestCustomerComponent }, + // { path: 'create/webshop-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'webshop' } }, + // { path: 'create/store-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'store' } }, + // { + // path: 'create/webshop-p4m/update', + // component: UpdateP4MWebshopCustomerComponent, + // data: { customerType: 'webshop' }, + // }, + { + path: 'create-customer-main', + outlet: 'side', + component: CustomerCreateSideViewComponent, + }, + ], + }, + ], + }, +]; diff --git a/apps/isa-app/src/page/dashboard/dashboard-routing-module.ts b/apps/isa-app/src/page/dashboard/dashboard-routing-module.ts index e38f3e1b6..7206032a1 100644 --- a/apps/isa-app/src/page/dashboard/dashboard-routing-module.ts +++ b/apps/isa-app/src/page/dashboard/dashboard-routing-module.ts @@ -1,16 +1,17 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; -import { DashboardComponent } from './dashboard.component'; - -const routes: Routes = [ - { - path: '', - component: DashboardComponent, - }, -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule], -}) -export class DashboardRoutingModule {} +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { DashboardComponent } from './dashboard.component'; + +const routes: Routes = [ + { + path: '', + component: DashboardComponent, + title: 'Dashboard', + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class DashboardRoutingModule {} diff --git a/apps/isa-app/src/page/goods-in/good-in-routing.module.ts b/apps/isa-app/src/page/goods-in/good-in-routing.module.ts index d0835b2e4..626b4ea8d 100644 --- a/apps/isa-app/src/page/goods-in/good-in-routing.module.ts +++ b/apps/isa-app/src/page/goods-in/good-in-routing.module.ts @@ -1,35 +1,51 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; -import { GoodsInCleanupListComponent } from './goods-in-cleanup-list/goods-in-cleanup-list.component'; -import { GoodsInCleanupListModule } from './goods-in-cleanup-list/goods-in-cleanup-list.module'; -import { GoodsInListComponent } from './goods-in-list/goods-in-list.component'; -import { GoodsInListModule } from './goods-in-list/goods-in-list.module'; -import { GoodsInRemissionPreviewComponent } from './goods-in-remission-preview/goods-in-remission-preview.component'; -import { GoodsInRemissionPreviewModule } from './goods-in-remission-preview/goods-in-remission-preview.module'; -import { GoodsInReservationComponent } from './goods-in-reservation/goods-in-reservation.component'; -import { GoodsInReservationModule } from './goods-in-reservation/goods-in-reservation.module'; -import { GoodsInComponent } from './goods-in.component'; - -const routes: Routes = [ - { - path: '', - component: GoodsInComponent, - children: [ - { path: 'list', component: GoodsInListComponent }, - { path: 'reservation', component: GoodsInReservationComponent }, - { path: 'cleanup', component: GoodsInCleanupListComponent }, - { path: 'preview', component: GoodsInRemissionPreviewComponent }, - ], - }, -]; -@NgModule({ - imports: [ - RouterModule.forChild(routes), - GoodsInListModule, - GoodsInCleanupListModule, - GoodsInReservationModule, - GoodsInRemissionPreviewModule, - ], - exports: [RouterModule], -}) -export class GoodsInRoutingModule {} +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { GoodsInCleanupListComponent } from './goods-in-cleanup-list/goods-in-cleanup-list.component'; +import { GoodsInCleanupListModule } from './goods-in-cleanup-list/goods-in-cleanup-list.module'; +import { GoodsInListComponent } from './goods-in-list/goods-in-list.component'; +import { GoodsInListModule } from './goods-in-list/goods-in-list.module'; +import { GoodsInRemissionPreviewComponent } from './goods-in-remission-preview/goods-in-remission-preview.component'; +import { GoodsInRemissionPreviewModule } from './goods-in-remission-preview/goods-in-remission-preview.module'; +import { GoodsInReservationComponent } from './goods-in-reservation/goods-in-reservation.component'; +import { GoodsInReservationModule } from './goods-in-reservation/goods-in-reservation.module'; +import { GoodsInComponent } from './goods-in.component'; + +const routes: Routes = [ + { + path: '', + component: GoodsInComponent, + children: [ + { + path: 'list', + component: GoodsInListComponent, + title: 'Abholfach - Fehlende', + }, + { + path: 'reservation', + component: GoodsInReservationComponent, + title: 'Abholfach - Reservierung', + }, + { + path: 'cleanup', + component: GoodsInCleanupListComponent, + title: 'Abholfach - Ausräumen', + }, + { + path: 'preview', + component: GoodsInRemissionPreviewComponent, + title: 'Abholfach - Vorschau', + }, + ], + }, +]; +@NgModule({ + imports: [ + RouterModule.forChild(routes), + GoodsInListModule, + GoodsInCleanupListModule, + GoodsInReservationModule, + GoodsInRemissionPreviewModule, + ], + exports: [RouterModule], +}) +export class GoodsInRoutingModule {} diff --git a/apps/isa-app/src/page/package-inspection/routes.ts b/apps/isa-app/src/page/package-inspection/routes.ts index efcfee2fe..79fa1ece7 100644 --- a/apps/isa-app/src/page/package-inspection/routes.ts +++ b/apps/isa-app/src/page/package-inspection/routes.ts @@ -1,22 +1,24 @@ -import { Routes } from '@angular/router'; -import { PackageDetailsComponent } from './package-details'; -import { PackageInspectionComponent } from './package-inspection.component'; -import { PackageResultComponent } from './package-result'; - -export const packageInspectionRoutes: Routes = [ - { - path: '', - component: PackageInspectionComponent, - children: [ - { - path: 'packages', - component: PackageResultComponent, - }, - { - path: 'packages/:id', - component: PackageDetailsComponent, - }, - { path: '**', redirectTo: 'packages' }, - ], - }, -]; +import { Routes } from '@angular/router'; +import { PackageDetailsComponent } from './package-details'; +import { PackageInspectionComponent } from './package-inspection.component'; +import { PackageResultComponent } from './package-result'; + +export const packageInspectionRoutes: Routes = [ + { + path: '', + component: PackageInspectionComponent, + title: 'Packstück-Prüfung', + children: [ + { + path: 'packages', + component: PackageResultComponent, + }, + { + path: 'packages/:id', + component: PackageDetailsComponent, + title: 'Packstück-Prüfung - Details', + }, + { path: '**', redirectTo: 'packages' }, + ], + }, +]; diff --git a/apps/isa-app/src/page/pickup-shelf/pickup-shelf-in/routes.ts b/apps/isa-app/src/page/pickup-shelf/pickup-shelf-in/routes.ts index 579adbb19..1c6ac9c7e 100644 --- a/apps/isa-app/src/page/pickup-shelf/pickup-shelf-in/routes.ts +++ b/apps/isa-app/src/page/pickup-shelf/pickup-shelf-in/routes.ts @@ -1,146 +1,168 @@ -import { Routes } from '@angular/router'; -import { PickupShelfInComponent } from './pickup-shelf-in.component'; -import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component'; -import { PickupShelfInDetailsComponent } from './pickup-shelf-in-details/pickup-shelf-in-details.component'; -import { viewResolver } from '../resolvers/view.resolver'; -import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component'; -import { PickUpShelfInMainSideViewComponent } from './pickup-shelf-in-main-side-view/pickup-shelf-in-main-side-view.component'; -import { PickUpShelfInMainComponent } from './pickup-shelf-in-main/pickup-shelf-in-main.component'; -import { PickUpShelfInListComponent } from './pickup-shelf-in-list/pickup-shelf-in-list.component'; -import { PickupShelfInEditComponent } from './pickup-shelf-in-edit/pickup-shelf-in-edit.component'; -import { MatomoRouteData } from 'ngx-matomo-client'; - -export const routes: Routes = [ - { - path: '', - component: PickupShelfInComponent, - resolve: { - view: viewResolver, - }, - runGuardsAndResolvers: 'always', - children: [ - { - path: 'main', - component: PickUpShelfInMainComponent, - data: { - view: 'main', - matomo: { - title: 'Abholfach', - } as MatomoRouteData, - }, - }, - { - path: 'list', - component: PickUpShelfInListComponent, - data: { - view: 'list', - matomo: { - title: 'Abholfach - Trefferliste', - } as MatomoRouteData, - }, - }, - { - path: 'list/filter', - component: PickupShelfFilterComponent, - data: { - view: 'filter', - matomo: { - title: 'Abholfach - Filter', - } as MatomoRouteData, - }, - }, - { - path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/edit', - component: PickupShelfInEditComponent, - data: { - view: 'edit', - matomo: { - title: 'Abholfach - Bearbeiten', - } as MatomoRouteData, - }, - }, - { - path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/edit', - component: PickupShelfInEditComponent, - data: { - view: 'edit', - matomo: { - title: 'Abholfach - Bearbeiten', - } as MatomoRouteData, - }, - }, - { - path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/edit', - component: PickupShelfInEditComponent, - data: { - view: 'edit', - matomo: { - title: 'Abholfach - Bearbeiten', - } as MatomoRouteData, - }, - }, - { - path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/history', - component: PickUpShelfHistoryComponent, - data: { - view: 'history', - matomo: { - title: 'Abholfach - Historie', - } as MatomoRouteData, - }, - }, - { - path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/history', - component: PickUpShelfHistoryComponent, - data: { - view: 'history', - matomo: { - title: 'Abholfach - Historie', - } as MatomoRouteData, - }, - }, - { - path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/history', - component: PickUpShelfHistoryComponent, - data: { - view: 'history', - matomo: { - title: 'Abholfach - Historie', - } as MatomoRouteData, - }, - }, - { - path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId', - component: PickupShelfInDetailsComponent, - data: { - view: 'details', - matomo: { - title: 'Abholfach - Details', - } as MatomoRouteData, - }, - }, - { - path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId', - component: PickupShelfInDetailsComponent, - data: { - view: 'details', - matomo: { - title: 'Abholfach - Details', - } as MatomoRouteData, - }, - }, - { - path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId', - component: PickupShelfInDetailsComponent, - data: { - view: 'details', - matomo: { - title: 'Abholfach - Details', - } as MatomoRouteData, - }, - }, - { path: 'search', component: PickUpShelfInMainSideViewComponent, outlet: 'side' }, - { path: 'list', component: PickUpShelfInListComponent, data: { view: 'list' }, outlet: 'side' }, - ], - }, -]; +import { Routes } from '@angular/router'; +import { PickupShelfInComponent } from './pickup-shelf-in.component'; +import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component'; +import { PickupShelfInDetailsComponent } from './pickup-shelf-in-details/pickup-shelf-in-details.component'; +import { viewResolver } from '../resolvers/view.resolver'; +import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component'; +import { PickUpShelfInMainSideViewComponent } from './pickup-shelf-in-main-side-view/pickup-shelf-in-main-side-view.component'; +import { PickUpShelfInMainComponent } from './pickup-shelf-in-main/pickup-shelf-in-main.component'; +import { PickUpShelfInListComponent } from './pickup-shelf-in-list/pickup-shelf-in-list.component'; +import { PickupShelfInEditComponent } from './pickup-shelf-in-edit/pickup-shelf-in-edit.component'; +import { MatomoRouteData } from 'ngx-matomo-client'; + +export const routes: Routes = [ + { + path: '', + component: PickupShelfInComponent, + resolve: { + view: viewResolver, + }, + runGuardsAndResolvers: 'always', + title: 'Abholfach - Einbuchen', + children: [ + { + path: 'main', + component: PickUpShelfInMainComponent, + data: { + view: 'main', + matomo: { + title: 'Abholfach', + } as MatomoRouteData, + }, + }, + { + path: 'list', + component: PickUpShelfInListComponent, + title: 'Abholfach - Trefferliste', + data: { + view: 'list', + matomo: { + title: 'Abholfach - Trefferliste', + } as MatomoRouteData, + }, + }, + { + path: 'list/filter', + component: PickupShelfFilterComponent, + title: 'Abholfach - Filter', + data: { + view: 'filter', + matomo: { + title: 'Abholfach - Filter', + } as MatomoRouteData, + }, + }, + { + path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/edit', + component: PickupShelfInEditComponent, + title: 'Abholfach - Bearbeiten', + data: { + view: 'edit', + matomo: { + title: 'Abholfach - Bearbeiten', + } as MatomoRouteData, + }, + }, + { + path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/edit', + component: PickupShelfInEditComponent, + title: 'Abholfach - Bearbeiten', + data: { + view: 'edit', + matomo: { + title: 'Abholfach - Bearbeiten', + } as MatomoRouteData, + }, + }, + { + path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/edit', + component: PickupShelfInEditComponent, + title: 'Abholfach - Bearbeiten', + data: { + view: 'edit', + matomo: { + title: 'Abholfach - Bearbeiten', + } as MatomoRouteData, + }, + }, + { + path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/history', + component: PickUpShelfHistoryComponent, + title: 'Abholfach - Historie', + data: { + view: 'history', + matomo: { + title: 'Abholfach - Historie', + } as MatomoRouteData, + }, + }, + { + path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/history', + component: PickUpShelfHistoryComponent, + title: 'Abholfach - Historie', + data: { + view: 'history', + matomo: { + title: 'Abholfach - Historie', + } as MatomoRouteData, + }, + }, + { + path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/history', + component: PickUpShelfHistoryComponent, + title: 'Abholfach - Historie', + data: { + view: 'history', + matomo: { + title: 'Abholfach - Historie', + } as MatomoRouteData, + }, + }, + { + path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId', + component: PickupShelfInDetailsComponent, + title: 'Abholfach - Details', + data: { + view: 'details', + matomo: { + title: 'Abholfach - Details', + } as MatomoRouteData, + }, + }, + { + path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId', + component: PickupShelfInDetailsComponent, + title: 'Abholfach - Details', + data: { + view: 'details', + matomo: { + title: 'Abholfach - Details', + } as MatomoRouteData, + }, + }, + { + path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId', + component: PickupShelfInDetailsComponent, + title: 'Abholfach - Details', + data: { + view: 'details', + matomo: { + title: 'Abholfach - Details', + } as MatomoRouteData, + }, + }, + { + path: 'search', + component: PickUpShelfInMainSideViewComponent, + + outlet: 'side', + }, + { + path: 'list', + component: PickUpShelfInListComponent, + data: { view: 'list' }, + outlet: 'side', + }, + ], + }, +]; diff --git a/apps/isa-app/src/page/pickup-shelf/pickup-shelf-out/routes.ts b/apps/isa-app/src/page/pickup-shelf/pickup-shelf-out/routes.ts index 1efa8c5f3..1fced0f6b 100644 --- a/apps/isa-app/src/page/pickup-shelf/pickup-shelf-out/routes.ts +++ b/apps/isa-app/src/page/pickup-shelf/pickup-shelf-out/routes.ts @@ -1,75 +1,107 @@ -import { Routes } from '@angular/router'; -import { PickupShelfOutComponent } from './pickup-shelf-out.component'; -import { PickupShelfOutMainComponent } from './pickup-shelf-out-main/pickup-shelf-out-main.component'; - -import { PickupShelfOutListComponent } from './pickup-shelf-out-list/pickup-shelf-out-list.component'; -import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component'; -import { PickupShelfOutDetailsComponent } from './pickup-shelf-out-details/pickup-shelf-out-details.component'; -import { PickupShelfOutMainSideViewComponent } from './pickup-shelf-out-main-side-view/pickup-shelf-out-main-side-view.component'; -import { viewResolver } from '../resolvers/view.resolver'; -import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component'; -import { PickupShelfOutEditComponent } from './pickup-shelf-out-edit/pickup-shelf-out-edit.component'; - -export const routes: Routes = [ - { - path: '', - component: PickupShelfOutComponent, - resolve: { - view: viewResolver, - }, - runGuardsAndResolvers: 'always', - children: [ - { path: 'main', component: PickupShelfOutMainComponent, data: { view: 'main' } }, - { path: 'list', component: PickupShelfOutListComponent, data: { view: 'list' } }, - { path: 'list/filter', component: PickupShelfFilterComponent, data: { view: 'filter' } }, - { - path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/edit', - component: PickupShelfOutEditComponent, - data: { view: 'edit' }, - }, - { - path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/edit', - component: PickupShelfOutEditComponent, - data: { view: 'edit' }, - }, - - { - path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/edit', - component: PickupShelfOutEditComponent, - data: { view: 'edit' }, - }, - { - path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/history', - component: PickUpShelfHistoryComponent, - data: { view: 'history' }, - }, - { - path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/history', - component: PickUpShelfHistoryComponent, - data: { view: 'history' }, - }, - { - path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/history', - component: PickUpShelfHistoryComponent, - data: { view: 'history' }, - }, - { - path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus', - component: PickupShelfOutDetailsComponent, - data: { view: 'details' }, - }, - { - path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus', - component: PickupShelfOutDetailsComponent, - data: { view: 'details' }, - }, - { - path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus', - component: PickupShelfOutDetailsComponent, - data: { view: 'details' }, - }, - { path: 'search', component: PickupShelfOutMainSideViewComponent, outlet: 'side' }, - { path: 'list', component: PickupShelfOutListComponent, data: { view: 'list' }, outlet: 'side' }, - ], - }, -]; +import { Routes } from '@angular/router'; +import { PickupShelfOutComponent } from './pickup-shelf-out.component'; +import { PickupShelfOutMainComponent } from './pickup-shelf-out-main/pickup-shelf-out-main.component'; + +import { PickupShelfOutListComponent } from './pickup-shelf-out-list/pickup-shelf-out-list.component'; +import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component'; +import { PickupShelfOutDetailsComponent } from './pickup-shelf-out-details/pickup-shelf-out-details.component'; +import { PickupShelfOutMainSideViewComponent } from './pickup-shelf-out-main-side-view/pickup-shelf-out-main-side-view.component'; +import { viewResolver } from '../resolvers/view.resolver'; +import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component'; +import { PickupShelfOutEditComponent } from './pickup-shelf-out-edit/pickup-shelf-out-edit.component'; + +export const routes: Routes = [ + { + path: '', + component: PickupShelfOutComponent, + resolve: { + view: viewResolver, + }, + runGuardsAndResolvers: 'always', + title: 'Warenausgabe', + children: [ + { + path: 'main', + component: PickupShelfOutMainComponent, + data: { view: 'main' }, + }, + { + path: 'list', + component: PickupShelfOutListComponent, + data: { view: 'list' }, + }, + { + path: 'list/filter', + component: PickupShelfFilterComponent, + data: { view: 'filter' }, + }, + { + path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/edit', + component: PickupShelfOutEditComponent, + title: 'Warenausgabe - Details', + data: { view: 'edit' }, + }, + { + path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/edit', + component: PickupShelfOutEditComponent, + title: 'Warenausgabe - Details', + data: { view: 'edit' }, + }, + + { + path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/edit', + component: PickupShelfOutEditComponent, + title: 'Warenausgabe - Details', + data: { view: 'edit' }, + }, + { + path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/history', + component: PickUpShelfHistoryComponent, + title: 'Warenausgabe - Verlauf', + data: { view: 'history' }, + }, + { + path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/history', + component: PickUpShelfHistoryComponent, + title: 'Warenausgabe - Verlauf', + data: { view: 'history' }, + }, + { + path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/history', + component: PickUpShelfHistoryComponent, + title: 'Warenausgabe - Verlauf', + data: { view: 'history' }, + }, + { + path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus', + component: PickupShelfOutDetailsComponent, + title: 'Warenausgabe - Details', + data: { view: 'details' }, + }, + { + path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus', + component: PickupShelfOutDetailsComponent, + title: 'Warenausgabe - Details', + data: { view: 'details' }, + }, + { + path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus', + component: PickupShelfOutDetailsComponent, + title: 'Warenausgabe - Details', + data: { view: 'details' }, + }, + { + path: 'search', + component: PickupShelfOutMainSideViewComponent, + outlet: 'side', + data: { view: 'search' }, + }, + { + path: 'list', + component: PickupShelfOutListComponent, + data: { view: 'list' }, + outlet: 'side', + }, + ], + }, +]; diff --git a/apps/isa-app/src/page/task-calendar/page-task-calendar-routing.module.ts b/apps/isa-app/src/page/task-calendar/page-task-calendar-routing.module.ts index b4287cc21..3d0f4e9d6 100644 --- a/apps/isa-app/src/page/task-calendar/page-task-calendar-routing.module.ts +++ b/apps/isa-app/src/page/task-calendar/page-task-calendar-routing.module.ts @@ -1,27 +1,36 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; -import { PageTaskCalendarComponent } from './page-task-calendar.component'; -import { CalendarComponent } from './pages/calendar/calendar.component'; -import { CalendarModule } from './pages/calendar/calendar.module'; -import { TaskSearchComponent } from './pages/task-search'; -import { TasksComponent } from './pages/tasks/tasks.component'; -import { TasksModule } from './pages/tasks/tasks.module'; - -const routes: Routes = [ - { - path: '', - component: PageTaskCalendarComponent, - children: [ - { path: 'calendar', component: CalendarComponent }, - { path: 'tasks', component: TasksComponent }, - { path: 'search', component: TaskSearchComponent }, - { path: '', pathMatch: 'full', redirectTo: 'tasks' }, - ], - }, -]; - -@NgModule({ - imports: [RouterModule.forChild(routes), CalendarModule, TasksModule], - exports: [RouterModule], -}) -export class PageTaskCalendarRoutingModule {} +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { PageTaskCalendarComponent } from './page-task-calendar.component'; +import { CalendarComponent } from './pages/calendar/calendar.component'; +import { CalendarModule } from './pages/calendar/calendar.module'; +import { TaskSearchComponent } from './pages/task-search'; +import { TasksComponent } from './pages/tasks/tasks.component'; +import { TasksModule } from './pages/tasks/tasks.module'; + +const routes: Routes = [ + { + path: '', + component: PageTaskCalendarComponent, + title: 'Tätigkeitskalender', + children: [ + { path: 'calendar', component: CalendarComponent }, + { + path: 'tasks', + title: 'Tätigkeitskalender - Aufgaben', + component: TasksComponent, + }, + { + path: 'search', + title: 'Tätigkeitskalender - Suche', + component: TaskSearchComponent, + }, + { path: '', pathMatch: 'full', redirectTo: 'tasks' }, + ], + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes), CalendarModule, TasksModule], + exports: [RouterModule], +}) +export class PageTaskCalendarRoutingModule {} diff --git a/libs/checkout/feature/reward-catalog/src/lib/routes.ts b/libs/checkout/feature/reward-catalog/src/lib/routes.ts index 10e45b694..491b898b1 100644 --- a/libs/checkout/feature/reward-catalog/src/lib/routes.ts +++ b/libs/checkout/feature/reward-catalog/src/lib/routes.ts @@ -6,6 +6,7 @@ export const routes: Routes = [ { path: '', component: RewardCatalogComponent, + title: 'Prämienshop', resolve: { querySettings: querySettingsResolverFn }, data: { scrollPositionRestoration: true, diff --git a/libs/checkout/feature/reward-order-confirmation/src/lib/routes.ts b/libs/checkout/feature/reward-order-confirmation/src/lib/routes.ts index 0cae641f8..5a226fb3d 100644 --- a/libs/checkout/feature/reward-order-confirmation/src/lib/routes.ts +++ b/libs/checkout/feature/reward-order-confirmation/src/lib/routes.ts @@ -6,6 +6,7 @@ import { canDeactivateTabCleanup } from '@isa/core/tabs'; export const routes: Routes = [ { path: ':displayOrderIds', + title: 'Prämienshop - Bestellbestätigung', providers: [ CoreCommandModule.forChild(OMS_ACTION_HANDLERS).providers ?? [], ], diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/routes.ts b/libs/checkout/feature/reward-shopping-cart/src/lib/routes.ts index 086c0fe59..369746e64 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/routes.ts +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/routes.ts @@ -4,6 +4,7 @@ import { RewardShoppingCartComponent } from './reward-shopping-cart.component'; export const routes: Routes = [ { path: '', + title: 'Prämienshop - Warenkorb', component: RewardShoppingCartComponent, }, ]; diff --git a/libs/common/title-management/README.md b/libs/common/title-management/README.md new file mode 100644 index 000000000..f336382fa --- /dev/null +++ b/libs/common/title-management/README.md @@ -0,0 +1,600 @@ +# @isa/common/title-management + +> Reusable title management patterns for Angular applications with reactive updates and tab integration. + +## Overview + +This library provides two complementary approaches for managing page titles in the ISA application: + +1. **`IsaTitleStrategy`** - A custom TitleStrategy for route-based static titles +2. **`usePageTitle()`** - A reactive helper function for component-based dynamic titles + +Both approaches automatically: +- Add the ISA prefix from config to all titles +- Update the TabService for multi-tab navigation +- Set the browser document title + +## When to Use What + +| Scenario | Recommended Approach | +|----------|---------------------| +| Static page title (never changes) | Route configuration with `IsaTitleStrategy` | +| Dynamic title based on user input (search, filters) | `usePageTitle()` in component | +| Title depends on loaded data (item name, ID) | `usePageTitle()` in component | +| Wizard/multi-step flows with changing steps | `usePageTitle()` in component | +| Combination of static base + dynamic suffix | Both (route + `usePageTitle()`) | + +## Installation + +This library is already installed and configured in your workspace. Import from: + +```typescript +import { IsaTitleStrategy, usePageTitle } from '@isa/common/title-management'; +``` + +## Setup + +### 1. Configure IsaTitleStrategy in AppModule + +To enable automatic title management for all routes, add the `IsaTitleStrategy` to your app providers: + +```typescript +// apps/isa-app/src/app/app.module.ts +import { TitleStrategy } from '@angular/router'; +import { IsaTitleStrategy } from '@isa/common/title-management'; + +@NgModule({ + providers: [ + { provide: TitleStrategy, useClass: IsaTitleStrategy } + ] +}) +export class AppModule {} +``` + +**Note:** This replaces Angular's default `TitleStrategy` with our custom implementation that adds the ISA prefix and updates tabs. + +## Usage + +### Static Titles (Route Configuration) + +For pages with fixed titles, simply add a `title` property to your route: + +```typescript +// In your routing module +const routes: Routes = [ + { + path: 'dashboard', + component: DashboardComponent, + title: 'Dashboard' // Will become "ISA - Dashboard" + }, + { + path: 'artikelsuche', + component: ArticleSearchComponent, + title: 'Artikelsuche' // Will become "ISA - Artikelsuche" + }, + { + path: 'returns', + component: ReturnsComponent, + title: 'Rückgaben' // Will become "ISA - Rückgaben" + } +]; +``` + +The `IsaTitleStrategy` will automatically: +- Add the configured prefix (default: "ISA") +- Update the active tab name +- Set the document title + +### Dynamic Titles (Component with Signals) + +For pages where the title depends on component state, use the `usePageTitle()` helper: + +#### Example 1: Search Page with Query Term + +```typescript +import { Component, signal, computed } from '@angular/core'; +import { usePageTitle } from '@isa/common/title-management'; + +@Component({ + selector: 'app-article-search', + standalone: true, + template: ` + +

{{ pageTitle().title }}

+ ` +}) +export class ArticleSearchComponent { + searchTerm = signal(''); + + // Computed signal that updates when searchTerm changes + pageTitle = computed(() => { + const term = this.searchTerm(); + return { + title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche' + }; + }); + + constructor() { + // Title updates automatically when searchTerm changes! + usePageTitle(this.pageTitle); + } +} +``` + +**Result:** +- Initial load: `ISA - Artikelsuche` +- After searching "Laptop": `ISA - Artikelsuche - "Laptop"` +- Tab name also updates automatically + +#### Example 2: Detail Page with Item Name + +```typescript +import { Component, signal, computed, inject, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { usePageTitle } from '@isa/common/title-management'; + +@Component({ + selector: 'app-product-details', + standalone: true, + template: `

{{ productName() || 'Loading...' }}

` +}) +export class ProductDetailsComponent implements OnInit { + private route = inject(ActivatedRoute); + + productName = signal(null); + + pageTitle = computed(() => { + const name = this.productName(); + return { + title: name ? `Produkt - ${name}` : 'Produkt Details' + }; + }); + + constructor() { + usePageTitle(this.pageTitle); + } + + ngOnInit() { + // Load product data... + this.productName.set('Samsung Galaxy S24'); + } +} +``` + +#### Example 3: Combining Route Title with Dynamic Content + +```typescript +import { Component, signal, computed, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { usePageTitle } from '@isa/common/title-management'; + +@Component({ + selector: 'app-order-wizard', + standalone: true, + template: `
Step {{ currentStep() }} of 3
` +}) +export class OrderWizardComponent { + private route = inject(ActivatedRoute); + + currentStep = signal(1); + + pageTitle = computed(() => { + // Get base title from route config + const baseTitle = this.route.snapshot.title || 'Bestellung'; + const step = this.currentStep(); + return { + title: `${baseTitle} - Schritt ${step}/3` + }; + }); + + constructor() { + usePageTitle(this.pageTitle); + } +} +``` + +### Nested Components (Parent/Child Routes) + +When using `usePageTitle()` in nested component hierarchies (parent/child routes), the **deepest component automatically wins**. When the child component is destroyed (e.g., navigating away), the parent's title is automatically restored. + +**This happens automatically** - no configuration or depth tracking needed! + +#### Example: Dashboard → Settings Flow + +```typescript +// Parent route: /dashboard +@Component({ + selector: 'app-dashboard', + standalone: true, + template: `` +}) +export class DashboardComponent { + pageTitle = signal({ title: 'Dashboard' }); + + constructor() { + usePageTitle(this.pageTitle); + // Sets: "ISA - Dashboard" + } +} + +// Child route: /dashboard/settings +@Component({ + selector: 'app-settings', + standalone: true, + template: `

Settings

` +}) +export class SettingsComponent { + pageTitle = signal({ title: 'Settings' }); + + constructor() { + usePageTitle(this.pageTitle); + // Sets: "ISA - Settings" (child wins!) + } +} + +// Navigation flow: +// 1. Navigate to /dashboard → Title: "ISA - Dashboard" +// 2. Navigate to /dashboard/settings → Title: "ISA - Settings" (child takes over) +// 3. Navigate back to /dashboard → Title: "ISA - Dashboard" (parent restored automatically) +``` + +#### How It Works + +The library uses an internal registry that tracks component creation order: +- **Last-registered (deepest) component controls the title** +- **Parent components' title updates are ignored** while child is active +- **Automatic cleanup** via Angular's `DestroyRef` - when child is destroyed, parent becomes active again + +#### Real-World Scenario + +```typescript +// Main page with search +@Component({...}) +export class ArticleSearchComponent { + searchTerm = signal(''); + + pageTitle = computed(() => { + const term = this.searchTerm(); + return { + title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche' + }; + }); + + constructor() { + usePageTitle(this.pageTitle); // "ISA - Artikelsuche" + } +} + +// Detail view (child route) +@Component({...}) +export class ArticleDetailsComponent { + articleName = signal('Samsung Galaxy S24'); + + pageTitle = computed(() => ({ + title: `Artikel - ${this.articleName()}` + })); + + constructor() { + usePageTitle(this.pageTitle); // "ISA - Artikel - Samsung Galaxy S24" (wins!) + } +} + +// When user closes detail view → "ISA - Artikelsuche" is restored automatically +``` + +#### Important Notes + +✅ **Works with any nesting depth** - Grandparent → Parent → Child → Grandchild + +✅ **No manual depth tracking** - Registration order determines precedence + +✅ **Automatic restoration** - Parent title restored when child is destroyed + +⚠️ **Parent signal updates are ignored** while child is active (by design!) + +### Tab Subtitles + +You can include a subtitle in the signal to display additional context in the tab: + +```typescript +constructor() { + this.pageTitle = signal({ + title: 'Dashboard', + subtitle: 'Active Orders' + }); + usePageTitle(this.pageTitle); +} +``` + +**Use Cases for Subtitles:** +- **Status indicators**: `"Pending"`, `"Active"`, `"Completed"` +- **Context information**: `"3 items"`, `"Last updated: 2min ago"` +- **Category labels**: `"Customer"`, `"Order"`, `"Product"` +- **Step indicators**: `"Step 2 of 5"`, `"Review"` + +#### Example: Order Processing with Status + +```typescript +@Component({ + selector: 'app-order-details', + standalone: true, + template: ` +

Order {{ orderId() }}

+

Status: {{ orderStatus() }}

+ ` +}) +export class OrderDetailsComponent { + orderId = signal('12345'); + orderStatus = signal<'pending' | 'processing' | 'complete'>('pending'); + + // Status labels for subtitle + statusLabels = { + pending: 'Awaiting Payment', + processing: 'In Progress', + complete: 'Completed' + }; + + pageTitle = computed(() => ({ + title: `Order ${this.orderId()}`, + subtitle: this.statusLabels[this.orderStatus()] + })); + + constructor() { + // Title and subtitle both update dynamically + usePageTitle(this.pageTitle); + } +} +``` + +#### Example: Search Results with Count + +```typescript +@Component({ + selector: 'app-article-search', + standalone: true, + template: `...` +}) +export class ArticleSearchComponent { + searchTerm = signal(''); + resultCount = signal(0); + + pageTitle = computed(() => { + const term = this.searchTerm(); + const count = this.resultCount(); + return { + title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche', + subtitle: `${count} Ergebnis${count === 1 ? '' : 'se'}` + }; + }); + + constructor() { + usePageTitle(this.pageTitle); + } +} +``` + +### Optional Title and Subtitle + +Both `title` and `subtitle` are optional. When a property is `undefined`, it will not be updated: + +#### Skip Title Update (Subtitle Only) + +```typescript +// Only update the subtitle, keep existing document title +pageTitle = signal({ subtitle: '3 items' }); +usePageTitle(this.pageTitle); +``` + +#### Skip Subtitle Update (Title Only) + +```typescript +// Only update the title, no subtitle +pageTitle = signal({ title: 'Dashboard' }); +usePageTitle(this.pageTitle); +``` + +#### Skip All Updates + +```typescript +// Empty object - skips all updates +pageTitle = signal({}); +usePageTitle(this.pageTitle); +``` + +#### Conditional Updates + +```typescript +// Skip title update when data is not loaded +pageTitle = computed(() => { + const name = this.productName(); + return { + title: name ? `Product - ${name}` : undefined, + subtitle: 'Loading...' + }; +}); +usePageTitle(this.pageTitle); +``` + +## Migration Guide + +### From Resolver-Based Titles + +If you're currently using a resolver for titles (e.g., `resolveTitle`), here's how to migrate: + +**Before (with resolver):** +```typescript +// In resolver file +export const resolveTitle: (keyOrTitle: string) => ResolveFn = + (keyOrTitle) => (route, state) => { + const config = inject(Config); + const title = inject(Title); + const tabService = inject(TabService); + + const titleFromConfig = config.get(`process.titles.${keyOrTitle}`, z.string().default(keyOrTitle)); + // ... manual title setting logic + return titleFromConfig; + }; + +// In routing module +{ + path: 'dashboard', + component: DashboardComponent, + resolve: { title: resolveTitle('Dashboard') } +} +``` + +**After (with IsaTitleStrategy):** +```typescript +// No resolver needed - just use route config +{ + path: 'dashboard', + component: DashboardComponent, + title: 'Dashboard' // Much simpler! +} +``` + +**For dynamic titles, use usePageTitle() instead:** +```typescript +// In component +pageTitle = computed(() => ({ title: this.dynamicTitle() })); + +constructor() { + usePageTitle(this.pageTitle); +} +``` + +## API Reference + +### `IsaTitleStrategy` + +Custom TitleStrategy implementation that extends Angular's TitleStrategy. + +**Methods:** +- `updateTitle(snapshot: RouterStateSnapshot): void` - Called automatically by Angular Router + +**Dependencies:** +- `@isa/core/config` - For title prefix configuration +- `@isa/core/tabs` - For tab name updates +- `@angular/platform-browser` - For document title updates + +**Configuration:** +```typescript +// In app providers +{ provide: TitleStrategy, useClass: IsaTitleStrategy } +``` + +### `usePageTitle(titleSubtitleSignal)` + +Reactive helper function for managing dynamic component titles and subtitles. + +**Parameters:** +- `titleSubtitleSignal: Signal` - A signal containing optional title and subtitle + +**Returns:** `void` + +**Dependencies:** +- `@isa/core/config` - For title prefix configuration +- `@isa/core/tabs` - For tab name updates +- `@angular/platform-browser` - For document title updates + +**Example:** +```typescript +const pageTitle = computed(() => ({ + title: `Search - ${query()}`, + subtitle: `${count()} results` +})); +usePageTitle(pageTitle); +``` + +### `PageTitleInput` + +Input interface for `usePageTitle()`. + +**Properties:** +- `title?: string` - Optional page title (without ISA prefix). When undefined, document title is not updated. +- `subtitle?: string` - Optional subtitle to display in the tab. When undefined, tab subtitle is not updated. + +## Best Practices + +### ✅ Do + +- Use route-based titles for static pages +- Use `usePageTitle()` for dynamic content-dependent titles +- Keep title signals computed from other signals for reactivity +- Use descriptive, user-friendly titles +- Combine route titles with component-level refinements for clarity +- Use subtitles for status indicators, context info, or step numbers +- Return `undefined` for title/subtitle when you want to skip updates + +### ❌ Don't + +- Add the "ISA" prefix manually (it's added automatically) +- Call `Title.setTitle()` directly (use these utilities instead) +- Create multiple effects updating the same title (use one computed signal) +- Put long, complex logic in title computations (keep them simple) + +## Examples from ISA Codebase + +### Artikelsuche (Search with Term) +```typescript +@Component({...}) +export class ArticleSearchComponent { + searchTerm = signal(''); + + pageTitle = computed(() => { + const term = this.searchTerm(); + return { + title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche' + }; + }); + + constructor() { + usePageTitle(this.pageTitle); + } +} +``` + +### Rückgabe Details (Return with ID) +```typescript +@Component({...}) +export class ReturnDetailsComponent { + returnId = signal(null); + + pageTitle = computed(() => { + const id = this.returnId(); + return { + title: id ? `Rückgabe - ${id}` : 'Rückgabe Details' + }; + }); + + constructor() { + usePageTitle(this.pageTitle); + } +} +``` + +## Testing + +Run tests for this library: + +```bash +npx nx test common-title-management --skip-nx-cache +``` + +## Architecture Notes + +This library is placed in the **common** domain (not core) because: +- It's a reusable utility pattern that features opt into +- Components can function without it (unlike core infrastructure) +- Provides patterns for solving a recurring problem (page titles) +- Similar to other common libraries like decorators and data-access utilities + +## Related Libraries + +- `@isa/core/config` - Configuration management +- `@isa/core/tabs` - Multi-tab navigation +- `@isa/core/navigation` - Navigation context preservation + +## Support + +For issues or questions, refer to the main ISA documentation or contact the development team. diff --git a/libs/common/title-management/eslint.config.cjs b/libs/common/title-management/eslint.config.cjs new file mode 100644 index 000000000..3484a6e9e --- /dev/null +++ b/libs/common/title-management/eslint.config.cjs @@ -0,0 +1,34 @@ +const nx = require('@nx/eslint-plugin'); +const baseConfig = require('../../../eslint.config.js'); + +module.exports = [ + ...baseConfig, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'common', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'common', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/libs/common/title-management/project.json b/libs/common/title-management/project.json new file mode 100644 index 000000000..bb80018f5 --- /dev/null +++ b/libs/common/title-management/project.json @@ -0,0 +1,20 @@ +{ + "name": "common-title-management", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/common/title-management/src", + "prefix": "common", + "projectType": "library", + "tags": [], + "targets": { + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../../coverage/libs/common/title-management" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/common/title-management/src/index.ts b/libs/common/title-management/src/index.ts new file mode 100644 index 000000000..eb17b138b --- /dev/null +++ b/libs/common/title-management/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/isa-title.strategy'; +export * from './lib/use-page-title.function'; +export * from './lib/title-management.types'; diff --git a/libs/common/title-management/src/lib/isa-title.strategy.spec.ts b/libs/common/title-management/src/lib/isa-title.strategy.spec.ts new file mode 100644 index 000000000..d10044aa2 --- /dev/null +++ b/libs/common/title-management/src/lib/isa-title.strategy.spec.ts @@ -0,0 +1,157 @@ +import { TestBed } from '@angular/core/testing'; +import { Title } from '@angular/platform-browser'; +import { RouterStateSnapshot } from '@angular/router'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { signal } from '@angular/core'; +import { IsaTitleStrategy } from './isa-title.strategy'; +import { TabService } from '@isa/core/tabs'; +import { TITLE_PREFIX } from './title-prefix'; + +describe('IsaTitleStrategy', () => { + let strategy: IsaTitleStrategy; + let titleServiceMock: { setTitle: ReturnType; getTitle: ReturnType }; + let tabServiceMock: { + activatedTabId: ReturnType>; + patchTab: ReturnType; + }; + + beforeEach(() => { + // Arrange - Create mocks + titleServiceMock = { + setTitle: vi.fn(), + getTitle: vi.fn().mockReturnValue(''), + }; + + tabServiceMock = { + activatedTabId: signal(123), + patchTab: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + IsaTitleStrategy, + { provide: Title, useValue: titleServiceMock }, + { provide: TITLE_PREFIX, useValue: 'ISA' }, + { provide: TabService, useValue: tabServiceMock }, + ], + }); + + strategy = TestBed.inject(IsaTitleStrategy); + }); + + it('should be created', () => { + expect(strategy).toBeTruthy(); + }); + + describe('updateTitle', () => { + it('should set document title with ISA prefix', () => { + // Arrange + const mockSnapshot = { + url: '/dashboard', + } as RouterStateSnapshot; + + // Mock buildTitle to return a specific title + vi.spyOn(strategy, 'buildTitle').mockReturnValue('Dashboard'); + + // Act + strategy.updateTitle(mockSnapshot); + + // Assert + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard'); + }); + + it('should update tab name via TabService', () => { + // Arrange + const mockSnapshot = { + url: '/search', + } as RouterStateSnapshot; + + vi.spyOn(strategy, 'buildTitle').mockReturnValue('Artikelsuche'); + + // Act + strategy.updateTitle(mockSnapshot); + + // Assert + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(123, { + name: 'Artikelsuche', + }); + }); + + it('should use custom prefix from TITLE_PREFIX', () => { + // Arrange - Reset TestBed and configure with custom TITLE_PREFIX + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + IsaTitleStrategy, + { provide: Title, useValue: titleServiceMock }, + { provide: TITLE_PREFIX, useValue: 'MyApp' }, // Custom prefix + { provide: TabService, useValue: tabServiceMock }, + ], + }); + + const customStrategy = TestBed.inject(IsaTitleStrategy); + + const mockSnapshot = { + url: '/settings', + } as RouterStateSnapshot; + + vi.spyOn(customStrategy, 'buildTitle').mockReturnValue('Settings'); + + // Act + customStrategy.updateTitle(mockSnapshot); + + // Assert + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('MyApp - Settings'); + }); + + it('should not update tab when activatedTabId is null', () => { + // Arrange + tabServiceMock.activatedTabId.set(null); + + const mockSnapshot = { + url: '/dashboard', + } as RouterStateSnapshot; + + vi.spyOn(strategy, 'buildTitle').mockReturnValue('Dashboard'); + + // Act + strategy.updateTitle(mockSnapshot); + + // Assert + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard'); + expect(tabServiceMock.patchTab).not.toHaveBeenCalled(); + }); + + it('should not update anything when buildTitle returns empty string', () => { + // Arrange + const mockSnapshot = { + url: '/no-title', + } as RouterStateSnapshot; + + vi.spyOn(strategy, 'buildTitle').mockReturnValue(''); + + // Act + strategy.updateTitle(mockSnapshot); + + // Assert + expect(titleServiceMock.setTitle).not.toHaveBeenCalled(); + expect(tabServiceMock.patchTab).not.toHaveBeenCalled(); + }); + + it('should not update anything when buildTitle returns undefined', () => { + // Arrange + const mockSnapshot = { + url: '/no-title', + } as RouterStateSnapshot; + + vi.spyOn(strategy, 'buildTitle').mockReturnValue(undefined as any); + + // Act + strategy.updateTitle(mockSnapshot); + + // Assert + expect(titleServiceMock.setTitle).not.toHaveBeenCalled(); + expect(tabServiceMock.patchTab).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/title-management/src/lib/isa-title.strategy.ts b/libs/common/title-management/src/lib/isa-title.strategy.ts new file mode 100644 index 000000000..bbb7568ce --- /dev/null +++ b/libs/common/title-management/src/lib/isa-title.strategy.ts @@ -0,0 +1,89 @@ +import { Injectable, inject, Injector } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { RouterStateSnapshot, TitleStrategy } from '@angular/router'; +import { TabService } from '@isa/core/tabs'; +import { TITLE_PREFIX } from './title-prefix'; + +/** + * Custom TitleStrategy for the ISA application that: + * 1. Automatically adds the ISA prefix from config to all page titles + * 2. Updates the TabService with the page title for tab management + * 3. Sets the browser document title + * + * @example + * ```typescript + * // In app.module.ts or app.config.ts + * import { TitleStrategy } from '@angular/router'; + * import { IsaTitleStrategy } from '@isa/common/title-management'; + * + * @NgModule({ + * providers: [ + * { provide: TitleStrategy, useClass: IsaTitleStrategy } + * ] + * }) + * export class AppModule {} + * ``` + * + * @example + * ```typescript + * // Usage in routes + * const routes: Routes = [ + * { + * path: 'dashboard', + * component: DashboardComponent, + * title: 'Dashboard' // Will become "ISA - Dashboard" + * }, + * { + * path: 'search', + * component: SearchComponent, + * title: 'Artikelsuche' // Will become "ISA - Artikelsuche" + * } + * ]; + * ``` + */ +@Injectable({ providedIn: 'root' }) +export class IsaTitleStrategy extends TitleStrategy { + readonly #title = inject(Title); + readonly #injector = inject(Injector); + readonly #titlePrefix = inject(TITLE_PREFIX); + + // Lazy injection to avoid circular dependency: + // TitleStrategy → TabService → UserStorageProvider → AuthService → Router → TitleStrategy + #getTabService() { + return this.#injector.get(TabService); + } + + /** + * Updates the page title when navigation occurs. + * This method is called automatically by Angular's Router. + * + * @param snapshot - The router state snapshot containing route data + */ + override updateTitle(snapshot: RouterStateSnapshot): void { + const pageTitle = this.buildTitle(snapshot); + + if (pageTitle) { + this.#updateTitleAndTab(pageTitle); + } + } + + /** + * Updates both the document title and the tab name with the given page title. + * + * @param pageTitle - The page title to set (without prefix) + * @private + */ + #updateTitleAndTab(pageTitle: string): void { + const fullTitle = `${this.#titlePrefix} - ${pageTitle}`; + + this.#title.setTitle(fullTitle); + + const tabService = this.#getTabService(); + const activeTabId = tabService.activatedTabId(); + if (activeTabId !== null) { + tabService.patchTab(activeTabId, { + name: pageTitle, + }); + } + } +} diff --git a/libs/common/title-management/src/lib/title-management.types.ts b/libs/common/title-management/src/lib/title-management.types.ts new file mode 100644 index 000000000..43e2a78e8 --- /dev/null +++ b/libs/common/title-management/src/lib/title-management.types.ts @@ -0,0 +1,29 @@ +/** + * Input type for the `usePageTitle` helper function. + * Contains optional title and subtitle properties that can be set independently. + */ +export interface PageTitleInput { + /** + * Optional page title (without ISA prefix). + * When undefined, the document title will not be updated. + * @example + * ```typescript + * { title: 'Dashboard' } + * { title: searchTerm() ? `Search - "${searchTerm()}"` : undefined } + * ``` + */ + title?: string; + + /** + * Optional subtitle to display in the tab. + * When undefined, the tab subtitle will not be updated. + * Useful for status indicators, context information, or step numbers. + * @example + * ```typescript + * { subtitle: 'Active Orders' } + * { subtitle: 'Step 2 of 5' } + * { title: 'Order Details', subtitle: 'Pending' } + * ``` + */ + subtitle?: string; +} diff --git a/libs/common/title-management/src/lib/title-prefix.ts b/libs/common/title-management/src/lib/title-prefix.ts new file mode 100644 index 000000000..715b26c40 --- /dev/null +++ b/libs/common/title-management/src/lib/title-prefix.ts @@ -0,0 +1,14 @@ +import { inject, InjectionToken } from '@angular/core'; +import { Config } from '@core/config'; +import { z } from 'zod'; + +export const TITLE_PREFIX = new InjectionToken( + 'isa.common.title-management.title-prefix', + { + providedIn: 'root', + factory: () => { + const config = inject(Config); + return config.get('title', z.string().default('ISA')); + }, + }, +); diff --git a/libs/common/title-management/src/lib/title-registry.service.spec.ts b/libs/common/title-management/src/lib/title-registry.service.spec.ts new file mode 100644 index 000000000..4821ee41f --- /dev/null +++ b/libs/common/title-management/src/lib/title-registry.service.spec.ts @@ -0,0 +1,255 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TitleRegistryService } from './title-registry.service'; + +describe('TitleRegistryService', () => { + let service: TitleRegistryService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [TitleRegistryService], + }); + + service = TestBed.inject(TitleRegistryService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('register', () => { + it('should register and immediately execute the updater', () => { + // Arrange + const updater = vi.fn(); + + // Act + service.register(updater); + + // Assert + expect(updater).toHaveBeenCalledOnce(); + }); + + it('should return a unique symbol for each registration', () => { + // Arrange + const updater1 = vi.fn(); + const updater2 = vi.fn(); + + // Act + const id1 = service.register(updater1); + const id2 = service.register(updater2); + + // Assert + expect(id1).not.toBe(id2); + expect(typeof id1).toBe('symbol'); + expect(typeof id2).toBe('symbol'); + }); + + it('should make the last registered updater the active one', () => { + // Arrange + const updater1 = vi.fn(); + const updater2 = vi.fn(); + const updater3 = vi.fn(); + + const id1 = service.register(updater1); + const id2 = service.register(updater2); + const id3 = service.register(updater3); + + vi.clearAllMocks(); + + // Act - Only the last registered should execute + service.updateIfActive(id1, updater1); + service.updateIfActive(id2, updater2); + service.updateIfActive(id3, updater3); + + // Assert + expect(updater1).not.toHaveBeenCalled(); + expect(updater2).not.toHaveBeenCalled(); + expect(updater3).toHaveBeenCalledOnce(); + }); + }); + + describe('unregister', () => { + it('should remove the registration', () => { + // Arrange + const updater = vi.fn(); + const id = service.register(updater); + vi.clearAllMocks(); + + // Act + service.unregister(id); + service.updateIfActive(id, updater); + + // Assert - Updater should not be called after unregistration + expect(updater).not.toHaveBeenCalled(); + }); + + it('should restore the previous registration when active one is unregistered', () => { + // Arrange + const updater1 = vi.fn(); + const updater2 = vi.fn(); + + const id1 = service.register(updater1); + const id2 = service.register(updater2); + + vi.clearAllMocks(); + + // Act - Unregister the active one (id2) + service.unregister(id2); + + // Assert - updater1 should have been called to restore title + expect(updater1).toHaveBeenCalledOnce(); + expect(updater2).not.toHaveBeenCalled(); + }); + + it('should make the previous registration active after unregistering current active', () => { + // Arrange + const updater1 = vi.fn(); + const updater2 = vi.fn(); + + const id1 = service.register(updater1); + const id2 = service.register(updater2); + + service.unregister(id2); + vi.clearAllMocks(); + + // Act - id1 should now be active + service.updateIfActive(id1, updater1); + service.updateIfActive(id2, updater2); + + // Assert + expect(updater1).toHaveBeenCalledOnce(); + expect(updater2).not.toHaveBeenCalled(); + }); + + it('should handle unregistering a non-active registration', () => { + // Arrange + const updater1 = vi.fn(); + const updater2 = vi.fn(); + const updater3 = vi.fn(); + + const id1 = service.register(updater1); + const id2 = service.register(updater2); + const id3 = service.register(updater3); + + vi.clearAllMocks(); + + // Act - Unregister the middle one (not active) + service.unregister(id2); + + // Assert - id3 should still be active, updater2 should not be called + service.updateIfActive(id3, updater3); + expect(updater2).not.toHaveBeenCalled(); + expect(updater3).toHaveBeenCalledOnce(); + }); + + it('should handle unregistering when no registrations remain', () => { + // Arrange + const updater = vi.fn(); + const id = service.register(updater); + vi.clearAllMocks(); + + // Act + service.unregister(id); + + // Assert - No errors should occur + service.updateIfActive(id, updater); + expect(updater).not.toHaveBeenCalled(); + }); + }); + + describe('updateIfActive', () => { + it('should execute updater only if it is the active registration', () => { + // Arrange + const updater1 = vi.fn(); + const updater2 = vi.fn(); + + const id1 = service.register(updater1); + const id2 = service.register(updater2); // This becomes active + + vi.clearAllMocks(); + + // Act + service.updateIfActive(id1, updater1); + service.updateIfActive(id2, updater2); + + // Assert + expect(updater1).not.toHaveBeenCalled(); // Not active + expect(updater2).toHaveBeenCalledOnce(); // Active + }); + + it('should not execute updater for unregistered id', () => { + // Arrange + const updater = vi.fn(); + const id = service.register(updater); + + service.unregister(id); + vi.clearAllMocks(); + + // Act + service.updateIfActive(id, updater); + + // Assert + expect(updater).not.toHaveBeenCalled(); + }); + }); + + describe('nested component scenario', () => { + it('should handle parent → child → back to parent flow', () => { + // Arrange - Simulate parent component + const parentUpdater = vi.fn(() => 'Parent Title'); + const parentId = service.register(parentUpdater); + + vi.clearAllMocks(); + + // Act 1 - Child component registers + const childUpdater = vi.fn(() => 'Child Title'); + const childId = service.register(childUpdater); + + // Assert 1 - Child is active + expect(childUpdater).toHaveBeenCalledOnce(); + + vi.clearAllMocks(); + + // Act 2 - Child component is destroyed + service.unregister(childId); + + // Assert 2 - Parent title is restored + expect(parentUpdater).toHaveBeenCalledOnce(); + + vi.clearAllMocks(); + + // Act 3 - Parent updates should work + service.updateIfActive(parentId, parentUpdater); + + // Assert 3 - Parent is active again + expect(parentUpdater).toHaveBeenCalledOnce(); + }); + + it('should handle three-level nesting (grandparent → parent → child)', () => { + // Arrange + const grandparentUpdater = vi.fn(); + const parentUpdater = vi.fn(); + const childUpdater = vi.fn(); + + const grandparentId = service.register(grandparentUpdater); + const parentId = service.register(parentUpdater); + const childId = service.register(childUpdater); + + vi.clearAllMocks(); + + // Act 1 - Verify child is active + service.updateIfActive(childId, childUpdater); + expect(childUpdater).toHaveBeenCalledOnce(); + + // Act 2 - Remove child, parent should become active + service.unregister(childId); + expect(parentUpdater).toHaveBeenCalledOnce(); + + vi.clearAllMocks(); + + // Act 3 - Remove parent, grandparent should become active + service.unregister(parentId); + expect(grandparentUpdater).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/libs/common/title-management/src/lib/title-registry.service.ts b/libs/common/title-management/src/lib/title-registry.service.ts new file mode 100644 index 000000000..5731ddadf --- /dev/null +++ b/libs/common/title-management/src/lib/title-registry.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@angular/core'; + +/** + * Internal service that tracks which component is currently managing the page title. + * This ensures that in nested component hierarchies (parent/child routes), + * the deepest (most recently registered) component's title takes precedence. + * + * When a child component is destroyed, the parent component's title is automatically restored. + * + * @internal + */ +@Injectable({ providedIn: 'root' }) +export class TitleRegistryService { + #registrations = new Map void>(); + #activeRegistration: symbol | null = null; + + /** + * Register a new title updater. The most recently registered updater + * becomes the active one and will control the title. + * + * This implements a stack-like behavior where the last component to register + * (deepest in the component hierarchy) takes precedence. + * + * @param updater - Function that updates the title + * @returns A symbol that uniquely identifies this registration + */ + register(updater: () => void): symbol { + const id = Symbol('title-registration'); + this.#registrations.set(id, updater); + this.#activeRegistration = id; + + // Execute the updater immediately since it's now active + updater(); + + return id; + } + + /** + * Unregister a title updater. If this was the active updater, + * the previous one (if any) becomes active and its title is restored. + * + * This ensures that when a child component is destroyed, the parent + * component's title is automatically restored. + * + * @param id - The symbol identifying the registration to remove + */ + unregister(id: symbol): void { + this.#registrations.delete(id); + + // If we just unregistered the active one, activate the most recent remaining one + if (this.#activeRegistration === id) { + // Get the last registration (most recent) + const entries = Array.from(this.#registrations.entries()); + if (entries.length > 0) { + const [lastId, lastUpdater] = entries[entries.length - 1]; + this.#activeRegistration = lastId; + // Restore the previous component's title + lastUpdater(); + } else { + this.#activeRegistration = null; + } + } + } + + /** + * Execute the updater only if it's the currently active registration. + * + * This prevents inactive (parent) components from overwriting the title + * set by active (child) components. + * + * @param id - The symbol identifying the registration + * @param updater - Function to execute if this registration is active + */ + updateIfActive(id: symbol, updater: () => void): void { + if (this.#activeRegistration === id) { + updater(); + } + } +} diff --git a/libs/common/title-management/src/lib/use-page-title.function.spec.ts b/libs/common/title-management/src/lib/use-page-title.function.spec.ts new file mode 100644 index 000000000..2f7b4d63d --- /dev/null +++ b/libs/common/title-management/src/lib/use-page-title.function.spec.ts @@ -0,0 +1,746 @@ +import { TestBed } from '@angular/core/testing'; +import { Component, signal, computed } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { usePageTitle } from './use-page-title.function'; +import { TabService } from '@isa/core/tabs'; +import { TITLE_PREFIX } from './title-prefix'; + +describe('usePageTitle', () => { + let titleServiceMock: { setTitle: ReturnType; getTitle: ReturnType }; + let tabServiceMock: { + activatedTabId: ReturnType>; + patchTab: ReturnType; + }; + + beforeEach(() => { + // Arrange - Create mocks + titleServiceMock = { + setTitle: vi.fn(), + getTitle: vi.fn().mockReturnValue(''), + }; + + tabServiceMock = { + activatedTabId: signal(456), + patchTab: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: Title, useValue: titleServiceMock }, + { provide: TITLE_PREFIX, useValue: 'ISA' }, + { provide: TabService, useValue: tabServiceMock }, + ], + }); + }); + + it('should set initial title on component creation', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + pageTitle = signal({ title: 'Dashboard' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act + TestBed.createComponent(TestComponent); + TestBed.flushEffects(); + + // Assert + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard'); + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, { + name: 'Dashboard', + }); + }); + + it('should update title when signal changes', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + searchTerm = signal(''); + pageTitle = computed(() => { + const term = this.searchTerm(); + return { + title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche', + }; + }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + const fixture = TestBed.createComponent(TestComponent); + const component = fixture.componentInstance; + TestBed.flushEffects(); + + // Clear previous calls + vi.clearAllMocks(); + + // Act + component.searchTerm.set('Laptop'); + TestBed.flushEffects(); + + // Assert + expect(titleServiceMock.setTitle).toHaveBeenCalledWith( + 'ISA - Artikelsuche - "Laptop"' + ); + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, { + name: 'Artikelsuche - "Laptop"', + }); + }); + + it('should not update tab when activatedTabId is null', () => { + // Arrange + tabServiceMock.activatedTabId.set(null); + + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + pageTitle = signal({ title: 'Profile' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act + TestBed.createComponent(TestComponent); + TestBed.flushEffects(); + + // Assert + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Profile'); + expect(tabServiceMock.patchTab).not.toHaveBeenCalled(); + }); + + it('should use custom prefix from TITLE_PREFIX', () => { + // Arrange + TestBed.overrideProvider(TITLE_PREFIX, { useValue: 'MyApp' }); + + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + pageTitle = signal({ title: 'About' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act + TestBed.createComponent(TestComponent); + TestBed.flushEffects(); + + // Assert + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('MyApp - About'); + }); + + it('should handle multiple signal updates correctly', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + counter = signal(0); + pageTitle = computed(() => ({ title: `Page ${this.counter()}` })); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + const fixture = TestBed.createComponent(TestComponent); + const component = fixture.componentInstance; + TestBed.flushEffects(); + + // Clear initial call + vi.clearAllMocks(); + + // Act + component.counter.set(1); + TestBed.flushEffects(); + component.counter.set(2); + TestBed.flushEffects(); + component.counter.set(3); + TestBed.flushEffects(); + + // Assert + expect(titleServiceMock.setTitle).toHaveBeenCalledTimes(3); + expect(titleServiceMock.setTitle).toHaveBeenNthCalledWith(1, 'ISA - Page 1'); + expect(titleServiceMock.setTitle).toHaveBeenNthCalledWith(2, 'ISA - Page 2'); + expect(titleServiceMock.setTitle).toHaveBeenNthCalledWith(3, 'ISA - Page 3'); + }); + + describe('nested components', () => { + it('should prioritize child component title over parent', () => { + // Arrange - Parent component + @Component({ + standalone: true, + template: '
Parent
', + }) + class ParentComponent { + pageTitle = signal({ title: 'Dashboard' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Child component + @Component({ + standalone: true, + template: '
Child
', + }) + class ChildComponent { + pageTitle = signal({ title: 'Settings' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act - Create parent first + TestBed.createComponent(ParentComponent); + TestBed.flushEffects(); + + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard'); + + vi.clearAllMocks(); + + // Act - Create child (should win) + TestBed.createComponent(ChildComponent); + TestBed.flushEffects(); + + // Assert - Child title should be set + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Settings'); + }); + + it('should restore parent title when child component is destroyed', () => { + // Arrange - Parent component + @Component({ + standalone: true, + template: '
Parent
', + }) + class ParentComponent { + pageTitle = signal({ title: 'Dashboard' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Child component + @Component({ + standalone: true, + template: '
Child
', + }) + class ChildComponent { + pageTitle = signal({ title: 'Settings' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act - Create both components + TestBed.createComponent(ParentComponent); + TestBed.flushEffects(); + + const childFixture = TestBed.createComponent(ChildComponent); + TestBed.flushEffects(); + + vi.clearAllMocks(); + + // Act - Destroy child + childFixture.destroy(); + + // Assert - Parent title should be restored + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard'); + }); + + it('should handle parent title updates when child is active', () => { + // Arrange - Parent component with mutable signal + @Component({ + standalone: true, + template: '
Parent
', + }) + class ParentComponent { + pageTitle = signal({ title: 'Dashboard' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Child component + @Component({ + standalone: true, + template: '
Child
', + }) + class ChildComponent { + pageTitle = signal({ title: 'Settings' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act - Create both + const parentFixture = TestBed.createComponent(ParentComponent); + const parent = parentFixture.componentInstance; + TestBed.flushEffects(); + + TestBed.createComponent(ChildComponent); + TestBed.flushEffects(); + + vi.clearAllMocks(); + + // Act - Parent tries to update (should be ignored while child is active) + parent.pageTitle.set({ title: 'Dashboard Updated' }); + TestBed.flushEffects(); + + // Assert - Title should still be child's (parent update ignored) + expect(titleServiceMock.setTitle).not.toHaveBeenCalled(); + }); + + it('should allow parent title updates after child is destroyed', () => { + // Arrange - Parent component with mutable signal + @Component({ + standalone: true, + template: '
Parent
', + }) + class ParentComponent { + pageTitle = signal({ title: 'Dashboard' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Child component + @Component({ + standalone: true, + template: '
Child
', + }) + class ChildComponent { + pageTitle = signal({ title: 'Settings' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act - Create both + const parentFixture = TestBed.createComponent(ParentComponent); + const parent = parentFixture.componentInstance; + TestBed.flushEffects(); + + const childFixture = TestBed.createComponent(ChildComponent); + TestBed.flushEffects(); + + // Destroy child + childFixture.destroy(); + + vi.clearAllMocks(); + + // Act - Parent updates now (should work) + parent.pageTitle.set({ title: 'Dashboard Updated' }); + TestBed.flushEffects(); + + // Assert - Parent update should be reflected + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard Updated'); + }); + + it('should handle three-level nesting (grandparent → parent → child)', () => { + // Arrange - Three levels of components + @Component({ + standalone: true, + template: '
Grandparent
', + }) + class GrandparentComponent { + pageTitle = signal({ title: 'Main' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + @Component({ + standalone: true, + template: '
Parent
', + }) + class ParentComponent { + pageTitle = signal({ title: 'Dashboard' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + @Component({ + standalone: true, + template: '
Child
', + }) + class ChildComponent { + pageTitle = signal({ title: 'Settings' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act - Create all three + TestBed.createComponent(GrandparentComponent); + TestBed.flushEffects(); + + TestBed.createComponent(ParentComponent); + TestBed.flushEffects(); + + const childFixture = TestBed.createComponent(ChildComponent); + TestBed.flushEffects(); + + // Verify child is active + expect(titleServiceMock.setTitle).toHaveBeenLastCalledWith('ISA - Settings'); + + vi.clearAllMocks(); + + // Act - Destroy child + childFixture.destroy(); + + // Assert - Parent title should be restored + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard'); + }); + }); + + describe('subtitle', () => { + it('should set tab subtitle when provided', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + pageTitle = signal({ title: 'Dashboard', subtitle: 'Overview' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act + TestBed.createComponent(TestComponent); + TestBed.flushEffects(); + + // Assert + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, { + name: 'Dashboard', + subtitle: 'Overview', + }); + }); + + it('should not include subtitle in patchTab when not provided', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + pageTitle = signal({ title: 'Dashboard' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act + TestBed.createComponent(TestComponent); + TestBed.flushEffects(); + + // Assert + const callArgs = tabServiceMock.patchTab.mock.calls[0]; + expect(callArgs[0]).toBe(456); + expect(callArgs[1]).toEqual({ name: 'Dashboard' }); + expect(callArgs[1]).not.toHaveProperty('subtitle'); + }); + + it('should maintain subtitle across title signal changes', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + searchTerm = signal(''); + pageTitle = computed(() => { + const term = this.searchTerm(); + return { + title: term ? `Search - "${term}"` : 'Search', + subtitle: 'Active', + }; + }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + const fixture = TestBed.createComponent(TestComponent); + const component = fixture.componentInstance; + TestBed.flushEffects(); + + vi.clearAllMocks(); + + // Act + component.searchTerm.set('Laptop'); + TestBed.flushEffects(); + + // Assert + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, { + name: 'Search - "Laptop"', + subtitle: 'Active', + }); + }); + + it('should handle nested components each with different subtitles', () => { + // Arrange - Parent component + @Component({ + standalone: true, + template: '
Parent
', + }) + class ParentComponent { + pageTitle = signal({ title: 'Dashboard', subtitle: 'Main' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Child component + @Component({ + standalone: true, + template: '
Child
', + }) + class ChildComponent { + pageTitle = signal({ title: 'Settings', subtitle: 'Preferences' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act - Create parent first + TestBed.createComponent(ParentComponent); + TestBed.flushEffects(); + + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, { + name: 'Dashboard', + subtitle: 'Main', + }); + + vi.clearAllMocks(); + + // Act - Create child (should win) + TestBed.createComponent(ChildComponent); + TestBed.flushEffects(); + + // Assert - Child subtitle should be set + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, { + name: 'Settings', + subtitle: 'Preferences', + }); + }); + }); + + describe('optional title/subtitle', () => { + it('should skip document title update when title is undefined', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + pageTitle = signal({ subtitle: 'Loading' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act + TestBed.createComponent(TestComponent); + TestBed.flushEffects(); + + // Assert + expect(titleServiceMock.setTitle).not.toHaveBeenCalled(); + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, { + subtitle: 'Loading', + }); + }); + + it('should skip tab subtitle update when subtitle is undefined', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + pageTitle = signal({ title: 'Dashboard' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act + TestBed.createComponent(TestComponent); + TestBed.flushEffects(); + + // Assert + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard'); + const callArgs = tabServiceMock.patchTab.mock.calls[0]; + expect(callArgs[1]).toEqual({ name: 'Dashboard' }); + expect(callArgs[1]).not.toHaveProperty('subtitle'); + }); + + it('should handle empty object (skip all updates)', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + pageTitle = signal({}); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act + TestBed.createComponent(TestComponent); + TestBed.flushEffects(); + + // Assert + expect(titleServiceMock.setTitle).not.toHaveBeenCalled(); + expect(tabServiceMock.patchTab).not.toHaveBeenCalled(); + }); + + it('should handle dynamic changes from defined to undefined', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + showTitle = signal(true); + pageTitle = computed(() => ({ + title: this.showTitle() ? 'Dashboard' : undefined, + subtitle: 'Active', + })); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + const fixture = TestBed.createComponent(TestComponent); + const component = fixture.componentInstance; + TestBed.flushEffects(); + + // Verify initial state + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard'); + + vi.clearAllMocks(); + + // Act - Hide title + component.showTitle.set(false); + TestBed.flushEffects(); + + // Assert - Title should not be updated, but subtitle should + expect(titleServiceMock.setTitle).not.toHaveBeenCalled(); + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, { + subtitle: 'Active', + }); + }); + + it('should handle both title and subtitle', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + pageTitle = signal({ title: 'Orders', subtitle: '3 items' }); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + // Act + TestBed.createComponent(TestComponent); + TestBed.flushEffects(); + + // Assert + expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Orders'); + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, { + name: 'Orders', + subtitle: '3 items', + }); + }); + + it('should handle subtitle only (no title)', () => { + // Arrange + @Component({ + standalone: true, + template: '
Test
', + }) + class TestComponent { + count = signal(5); + pageTitle = computed(() => ({ + subtitle: `${this.count()} items`, + })); + + constructor() { + usePageTitle(this.pageTitle); + } + } + + const fixture = TestBed.createComponent(TestComponent); + const component = fixture.componentInstance; + TestBed.flushEffects(); + + // Assert initial + expect(titleServiceMock.setTitle).not.toHaveBeenCalled(); + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, { + subtitle: '5 items', + }); + + vi.clearAllMocks(); + + // Act - Update count + component.count.set(10); + TestBed.flushEffects(); + + // Assert - Only subtitle updates + expect(titleServiceMock.setTitle).not.toHaveBeenCalled(); + expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, { + subtitle: '10 items', + }); + }); + }); +}); diff --git a/libs/common/title-management/src/lib/use-page-title.function.ts b/libs/common/title-management/src/lib/use-page-title.function.ts new file mode 100644 index 000000000..06d71b2c2 --- /dev/null +++ b/libs/common/title-management/src/lib/use-page-title.function.ts @@ -0,0 +1,203 @@ +import { inject, effect, Signal, DestroyRef } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TabService } from '@isa/core/tabs'; +import { PageTitleInput } from './title-management.types'; +import { TitleRegistryService } from './title-registry.service'; +import { TITLE_PREFIX } from './title-prefix'; + +/** + * Reactive helper function for managing dynamic page titles and subtitles in components. + * Uses Angular signals and effects to automatically update the document title + * and tab name/subtitle whenever the provided signal changes. + * + * This is ideal for pages where the title depends on component state, such as: + * - Search pages with query terms + * - Detail pages with item names + * - Wizard flows with step names + * - Filter pages with applied filters + * - Status indicators with changing subtitles + * + * @param titleSubtitleSignal - A signal containing optional title and subtitle + * + * @example + * ```typescript + * // Basic usage with static title + * import { Component, signal } from '@angular/core'; + * import { usePageTitle } from '@isa/common/title-management'; + * + * @Component({ + * selector: 'app-dashboard', + * template: `

Dashboard

` + * }) + * export class DashboardComponent { + * pageTitle = signal({ title: 'Dashboard' }); + * + * constructor() { + * usePageTitle(this.pageTitle); + * } + * } + * ``` + * + * @example + * ```typescript + * // Dynamic title with search term + * import { Component, signal, computed } from '@angular/core'; + * import { usePageTitle } from '@isa/common/title-management'; + * + * @Component({ + * selector: 'app-article-search', + * template: `` + * }) + * export class ArticleSearchComponent { + * searchTerm = signal(''); + * + * pageTitle = computed(() => { + * const term = this.searchTerm(); + * return { + * title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche' + * }; + * }); + * + * constructor() { + * usePageTitle(this.pageTitle); + * // Title updates automatically when searchTerm changes! + * } + * } + * ``` + * + * @example + * ```typescript + * // Title with subtitle + * import { Component, signal, computed } from '@angular/core'; + * import { usePageTitle } from '@isa/common/title-management'; + * + * @Component({ + * selector: 'app-order-details', + * template: `

Order Details

` + * }) + * export class OrderDetailsComponent { + * orderId = signal('12345'); + * orderStatus = signal<'pending' | 'processing' | 'complete'>('pending'); + * + * statusLabels = { + * pending: 'Awaiting Payment', + * processing: 'In Progress', + * complete: 'Completed' + * }; + * + * pageTitle = computed(() => ({ + * title: `Order ${this.orderId()}`, + * subtitle: this.statusLabels[this.orderStatus()] + * })); + * + * constructor() { + * usePageTitle(this.pageTitle); + * } + * } + * ``` + * + * @example + * ```typescript + * // Skip title when undefined (e.g., data not loaded yet) + * import { Component, signal, computed } from '@angular/core'; + * import { usePageTitle } from '@isa/common/title-management'; + * + * @Component({ + * selector: 'app-product-details', + * template: `

{{ productName() || 'Loading...' }}

` + * }) + * export class ProductDetailsComponent { + * productName = signal(null); + * + * pageTitle = computed(() => { + * const name = this.productName(); + * return { + * title: name ? `Product - ${name}` : undefined + * }; + * }); + * + * constructor() { + * usePageTitle(this.pageTitle); + * // Title only updates when productName is not null + * } + * } + * ``` + * + * @example + * ```typescript + * // Nested components - child component's title automatically takes precedence + * // Parent component (route: /dashboard) + * export class DashboardComponent { + * pageTitle = signal({ title: 'Dashboard' }); + * constructor() { + * usePageTitle(this.pageTitle); // "ISA - Dashboard" + * } + * } + * + * // Child component (route: /dashboard/settings) + * export class SettingsComponent { + * pageTitle = signal({ title: 'Settings' }); + * constructor() { + * usePageTitle(this.pageTitle); // "ISA - Settings" (wins!) + * } + * } + * // When SettingsComponent is destroyed → "ISA - Dashboard" (automatically restored) + * ``` + */ +export function usePageTitle( + titleSubtitleSignal: Signal +): void { + const title = inject(Title); + const titlePrefix = inject(TITLE_PREFIX); + const tabService = inject(TabService); + const registry = inject(TitleRegistryService); + const destroyRef = inject(DestroyRef); + + // Create the updater function that will be called by the registry + const updateTitle = () => { + const { title: pageTitle, subtitle } = titleSubtitleSignal(); + + // Update document title if title is defined + if (pageTitle !== undefined) { + const fullTitle = `${titlePrefix} - ${pageTitle}`; + title.setTitle(fullTitle); + } + + // Update tab if activeTabId exists + const activeTabId = tabService.activatedTabId(); + if (activeTabId !== null) { + // Build patch object conditionally based on what's defined + const patch: { name?: string; subtitle?: string } = {}; + + if (pageTitle !== undefined) { + patch.name = pageTitle; + } + + if (subtitle !== undefined) { + patch.subtitle = subtitle; + } + + // Only patch if we have something to update + if (Object.keys(patch).length > 0) { + tabService.patchTab(activeTabId, patch); + } + } + }; + + // Register with the registry (this will execute updateTitle immediately) + const registrationId = registry.register(updateTitle); + + // Automatically unregister when component is destroyed + destroyRef.onDestroy(() => { + registry.unregister(registrationId); + }); + + // React to signal changes, but only update if this is the active registration + effect(() => { + // Access the signal to track it as a dependency + titleSubtitleSignal(); + + // Only update if this component is the active one + registry.updateIfActive(registrationId, updateTitle); + }); +} diff --git a/libs/common/title-management/src/test-setup.ts b/libs/common/title-management/src/test-setup.ts new file mode 100644 index 000000000..cebf5ae72 --- /dev/null +++ b/libs/common/title-management/src/test-setup.ts @@ -0,0 +1,13 @@ +import '@angular/compiler'; +import '@analogjs/vitest-angular/setup-zone'; + +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; +import { getTestBed } from '@angular/core/testing'; + +getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), +); diff --git a/libs/common/title-management/tsconfig.json b/libs/common/title-management/tsconfig.json new file mode 100644 index 000000000..3268ed4dc --- /dev/null +++ b/libs/common/title-management/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "importHelpers": true, + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/common/title-management/tsconfig.lib.json b/libs/common/title-management/tsconfig.lib.json new file mode 100644 index 000000000..312ee86bb --- /dev/null +++ b/libs/common/title-management/tsconfig.lib.json @@ -0,0 +1,27 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts", + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/common/title-management/tsconfig.spec.json b/libs/common/title-management/tsconfig.spec.json new file mode 100644 index 000000000..5785a8a5f --- /dev/null +++ b/libs/common/title-management/tsconfig.spec.json @@ -0,0 +1,29 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "files": ["src/test-setup.ts"] +} diff --git a/libs/common/title-management/vite.config.mts b/libs/common/title-management/vite.config.mts new file mode 100644 index 000000000..0a6c852da --- /dev/null +++ b/libs/common/title-management/vite.config.mts @@ -0,0 +1,33 @@ +/// +import { defineConfig } from 'vite'; +import angular from '@analogjs/vite-plugin-angular'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; + +export default +// @ts-expect-error - Vitest reporter tuple types have complex inference issues, but config works correctly at runtime +defineConfig(() => ({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/common/title-management', + plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])], + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + test: { + watch: false, + globals: true, + environment: 'jsdom', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + setupFiles: ['src/test-setup.ts'], + reporters: [ + 'default', + ['junit', { outputFile: '../../../testresults/junit-common-title-management.xml' }], + ], + coverage: { + reportsDirectory: '../../../coverage/libs/common/title-management', + provider: 'v8' as const, + reporter: ['text', 'cobertura'], + }, + }, +})); diff --git a/libs/core/storage/src/lib/storage-providers/user.storage-provider.ts b/libs/core/storage/src/lib/storage-providers/user.storage-provider.ts index 6b9cf58ec..b27c46dde 100644 --- a/libs/core/storage/src/lib/storage-providers/user.storage-provider.ts +++ b/libs/core/storage/src/lib/storage-providers/user.storage-provider.ts @@ -1,4 +1,4 @@ -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, Injector } from '@angular/core'; import { StorageProvider } from './storage-provider'; import { ResponseArgsOfUserState, @@ -32,9 +32,13 @@ const DEFAULT_USER_STATE_RESPONSE: ResponseArgsOfUserState = { export class UserStorageProvider implements StorageProvider { #logger = logger(() => ({ service: 'UserStorageProvider' })); #userStateService = inject(UserStateService); - #authService = inject(AuthService); + #injector = inject(Injector); - #loadUserState = this.#authService.authenticated$.pipe( + #getAuthService() { + return this.#injector.get(AuthService); + } + + #loadUserState = this.#getAuthService().authenticated$.pipe( filter((authenticated) => authenticated), switchMap(() => this.#userStateService.UserStateGetUserState().pipe( diff --git a/libs/core/tabs/README.md b/libs/core/tabs/README.md index 7d37a8bb0..3cdcb0afd 100644 --- a/libs/core/tabs/README.md +++ b/libs/core/tabs/README.md @@ -142,6 +142,7 @@ Each tab is stored as an NgRx entity with the following structure: interface Tab { id: number; // Unique identifier name: string; // Display name + subtitle: string; // Subtitle (defaults to empty string) createdAt: number; // Creation timestamp (ms) activatedAt?: number; // Last activation timestamp metadata: Record; // Flexible metadata storage @@ -295,6 +296,7 @@ Creates and adds a new tab to the store. **Parameters:** - `params: AddTabInput` - Tab creation parameters (validated with Zod) - `name?: string` - Display name (default: 'Neuer Vorgang') + - `subtitle?: string` - Subtitle (default: '') - `tags?: string[]` - Initial tags (default: []) - `metadata?: Record` - Initial metadata (default: {}) - `id?: number` - Optional ID (auto-generated if omitted) @@ -306,6 +308,7 @@ Creates and adds a new tab to the store. ```typescript const tab = this.#tabService.addTab({ name: 'Customer Order #1234', + subtitle: 'Pending approval', tags: ['order', 'customer'], metadata: { orderId: 1234, status: 'pending' } }); @@ -337,6 +340,7 @@ Partially updates a tab's properties. - `id: number` - Tab ID to update - `changes: PatchTabInput` - Partial tab updates - `name?: string` - Updated display name + - `subtitle?: string` - Updated subtitle - `tags?: string[]` - Updated tags array - `metadata?: Record` - Metadata to merge - `location?: TabLocationHistory` - Updated location history @@ -345,6 +349,7 @@ Partially updates a tab's properties. ```typescript this.#tabService.patchTab(42, { name: 'Updated Name', + subtitle: 'New subtitle', tags: ['new-tag'], metadata: { additionalField: 'value' } }); diff --git a/libs/core/tabs/src/lib/schemas.ts b/libs/core/tabs/src/lib/schemas.ts index 5c2a1b74a..be9cc2c6b 100644 --- a/libs/core/tabs/src/lib/schemas.ts +++ b/libs/core/tabs/src/lib/schemas.ts @@ -100,6 +100,8 @@ export const TabSchema = z.object({ id: z.number(), /** Display name for the tab (minimum 1 character) */ name: z.string().default('Neuer Vorgang'), + /** Subtitle for the tab */ + subtitle: z.string().default(''), /** Creation timestamp (milliseconds since epoch) */ createdAt: z.number(), /** Last activation timestamp (optional) */ @@ -123,6 +125,8 @@ export interface Tab { id: number; /** Display name for the tab */ name: string; + /** Subtitle for the tab */ + subtitle: string; /** Creation timestamp */ createdAt: number; /** Last activation timestamp */ @@ -146,6 +150,8 @@ export interface TabCreate { id?: number; /** Display name for the tab */ name: string; + /** Subtitle for the tab */ + subtitle?: string; /** Creation timestamp */ createdAt: number; /** Last activation timestamp */ @@ -170,6 +176,8 @@ export const PersistedTabSchema = z id: z.number(), /** Tab display name */ name: z.string().default('Neuer Vorgang'), + /** Tab subtitle */ + subtitle: z.string(), /** Creation timestamp */ createdAt: z.number(), /** Last activation timestamp */ @@ -195,6 +203,8 @@ export type TabInput = z.input; export const AddTabSchema = z.object({ /** Display name for the new tab */ name: z.string().default('Neuer Vorgang'), + /** Subtitle for the new tab */ + subtitle: z.string().default(''), /** Initial tags for the tab */ tags: TabTagsSchema, /** Initial metadata for the tab */ @@ -221,6 +231,8 @@ export const TabUpdateSchema = z .object({ /** Updated display name */ name: z.string().min(1).optional(), + /** Updated subtitle */ + subtitle: z.string().optional(), /** Updated activation timestamp */ activatedAt: z.number().optional(), /** Updated metadata object */ diff --git a/libs/core/tabs/src/lib/tab.ts b/libs/core/tabs/src/lib/tab.ts index 8f8ac9171..8d5921399 100644 --- a/libs/core/tabs/src/lib/tab.ts +++ b/libs/core/tabs/src/lib/tab.ts @@ -63,6 +63,7 @@ export const TabService = signalStore( const tab: Tab = { id: parsed.id ?? store._generateId(), name: parsed.name, + subtitle: parsed.subtitle, createdAt: Date.now(), activatedAt: parsed.activatedAt, tags: parsed.tags, diff --git a/libs/oms/feature/return-search/src/lib/routes.ts b/libs/oms/feature/return-search/src/lib/routes.ts index e3851e8c2..6d589bede 100644 --- a/libs/oms/feature/return-search/src/lib/routes.ts +++ b/libs/oms/feature/return-search/src/lib/routes.ts @@ -1,31 +1,34 @@ -import { Routes } from '@angular/router'; -import { ReturnSearchMainComponent } from './return-search-main/return-search-main.component'; -import { ReturnSearchComponent } from './return-search.component'; -import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn'; -import { ReturnSearchResultComponent } from './return-search-result/return-search-result.component'; - -export const routes: Routes = [ - { - path: '', - component: ReturnSearchComponent, - resolve: { querySettings: querySettingsResolverFn }, - children: [ - { path: '', component: ReturnSearchMainComponent }, - { - path: 'receipts', - component: ReturnSearchResultComponent, - data: { scrollPositionRestoration: true }, - }, - ], - }, - { - path: 'receipt', - loadChildren: () => - import('@isa/oms/feature/return-details').then((feat) => feat.routes), - }, - { - path: 'process', - loadChildren: () => - import('@isa/oms/feature/return-process').then((feat) => feat.routes), - }, -]; +import { Routes } from '@angular/router'; +import { ReturnSearchMainComponent } from './return-search-main/return-search-main.component'; +import { ReturnSearchComponent } from './return-search.component'; +import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn'; +import { ReturnSearchResultComponent } from './return-search-result/return-search-result.component'; + +export const routes: Routes = [ + { + path: '', + component: ReturnSearchComponent, + resolve: { querySettings: querySettingsResolverFn }, + title: 'Retoure', + children: [ + { path: '', component: ReturnSearchMainComponent }, + { + path: 'receipts', + component: ReturnSearchResultComponent, + data: { scrollPositionRestoration: true }, + }, + ], + }, + { + path: 'receipt', + title: 'Retoure - Beleg', + loadChildren: () => + import('@isa/oms/feature/return-details').then((feat) => feat.routes), + }, + { + path: 'process', + title: 'Retoure - Prozess', + loadChildren: () => + import('@isa/oms/feature/return-process').then((feat) => feat.routes), + }, +]; diff --git a/libs/remission/feature/remission-list/src/lib/routes.ts b/libs/remission/feature/remission-list/src/lib/routes.ts index e6d01393e..bee6df9cb 100644 --- a/libs/remission/feature/remission-list/src/lib/routes.ts +++ b/libs/remission/feature/remission-list/src/lib/routes.ts @@ -1,31 +1,33 @@ -import { Routes } from '@angular/router'; -import { RemissionListComponent } from './remission-list.component'; -import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn'; -import { querySettingsDepartmentResolverFn } from './resolvers/query-settings-department.resolver-fn'; -import { RemissionListType } from '@isa/remission/data-access'; - -export const routes: Routes = [ - { - path: 'mandatory', - component: RemissionListComponent, - resolve: { querySettings: querySettingsResolverFn }, - data: { - scrollPositionRestoration: true, - remiType: RemissionListType.Pflicht, - }, - }, - { - path: 'department', - component: RemissionListComponent, - resolve: { querySettings: querySettingsDepartmentResolverFn }, - data: { - scrollPositionRestoration: true, - remiType: RemissionListType.Abteilung, - }, - }, - { - path: '', - redirectTo: 'mandatory', - pathMatch: 'full', - }, -]; +import { Routes } from '@angular/router'; +import { RemissionListComponent } from './remission-list.component'; +import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn'; +import { querySettingsDepartmentResolverFn } from './resolvers/query-settings-department.resolver-fn'; +import { RemissionListType } from '@isa/remission/data-access'; + +export const routes: Routes = [ + { + path: 'mandatory', + component: RemissionListComponent, + resolve: { querySettings: querySettingsResolverFn }, + title: 'Remission - Pflicht', + data: { + scrollPositionRestoration: true, + remiType: RemissionListType.Pflicht, + }, + }, + { + path: 'department', + component: RemissionListComponent, + resolve: { querySettings: querySettingsDepartmentResolverFn }, + title: 'Remission - Abteilung', + data: { + scrollPositionRestoration: true, + remiType: RemissionListType.Abteilung, + }, + }, + { + path: '', + redirectTo: 'mandatory', + pathMatch: 'full', + }, +]; diff --git a/libs/remission/feature/remission-return-receipt-list/src/lib/routes.ts b/libs/remission/feature/remission-return-receipt-list/src/lib/routes.ts index 186832729..747c4e679 100644 --- a/libs/remission/feature/remission-return-receipt-list/src/lib/routes.ts +++ b/libs/remission/feature/remission-return-receipt-list/src/lib/routes.ts @@ -4,12 +4,14 @@ export const routes: Routes = [ { path: '', component: RemissionReturnReceiptListComponent, + title: 'Remission', data: { scrollPositionRestoration: true, }, }, { path: ':returnId/:receiptId', + title: 'Remission - Beleg', loadComponent: () => import('@isa/remission/feature/remission-return-receipt-details').then( (m) => m.RemissionReturnReceiptDetailsComponent, diff --git a/package-lock.json b/package-lock.json index e59fa4e67..5d5b04f15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13674,9 +13674,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", - "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", "peer": true, diff --git a/tsconfig.base.json b/tsconfig.base.json index 9985c12e0..0f5b3d3db 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -65,6 +65,9 @@ "@isa/common/data-access": ["libs/common/data-access/src/index.ts"], "@isa/common/decorators": ["libs/common/decorators/src/index.ts"], "@isa/common/print": ["libs/common/print/src/index.ts"], + "@isa/common/title-management": [ + "libs/common/title-management/src/index.ts" + ], "@isa/core/auth": ["libs/core/auth/src/index.ts"], "@isa/core/config": ["libs/core/config/src/index.ts"], "@isa/core/logging": ["libs/core/logging/src/index.ts"],