♻️ refactor(core-tabs): restructure library and merge title-management

- Reorganize core/tabs internal structure:
  - Move guards to `guards/` folder
  - Move internal services to `internal/` folder
  - Move resolvers to `resolvers/` folder
  - Move components to `components/` folder
- Migrate title-management library into core/tabs:
  - Move TitleStrategy to `internal/tab-title.strategy.ts`
  - Add `provideCoreTabs()` provider function
  - Delete common/title-management library
- Improve code quality from review findings:
  - Add SVG validation before bypassSecurityTrustHtml (security)
  - Convert TokenLoginComponent to standalone with inject()
  - Fix typo: laoderElement → loaderElement
  - Improve type safety in oms.service.ts
  - Rename getCurrentLocation → getCurrentLocationWithValidation
  - Remove empty ngOnInit from AbstractCreateCustomer
- Update shell-tab-item component with new styling and animations
This commit is contained in:
Lorenz Hilpert
2025-12-15 21:06:47 +01:00
parent eca1e5b8b1
commit 4af0c3de3c
56 changed files with 3809 additions and 5432 deletions

View File

@@ -1,5 +1,4 @@
import { version } from '../../../../package.json';
import { IsaTitleStrategy } from '@isa/common/title-management';
import {
HTTP_INTERCEPTORS,
HttpInterceptorFn,
@@ -21,11 +20,7 @@ import {
isDevMode,
} from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import {
provideRouter,
TitleStrategy,
withComponentInputBinding,
} from '@angular/router';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { ActionReducer, MetaReducer, provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
@@ -58,14 +53,7 @@ import {
} from '@adapter/scan';
import * as Commands from './commands';
import { NativeContainerService } from '@external/native-container';
import { ShellModule } from '@shared/shell';
import { IconModule } from '@shared/components/icon';
import { NgIconsModule } from '@ng-icons/core';
import {
matClose,
matWifi,
matWifiOff,
} from '@ng-icons/material-icons/baseline';
import { NetworkStatusService } from '@isa/core/connectivity';
import { debounceTime, filter, firstValueFrom, switchMap } from 'rxjs';
import { provideMatomo, withRouter, withRouteData } from 'ngx-matomo-client';
@@ -86,7 +74,6 @@ import { Store } from '@ngrx/store';
import { OAuthService } from 'angular-oauth2-oidc';
import z from 'zod';
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
import { TabNavigationService } from '@isa/core/tabs';
// Domain modules
import { provideDomainCheckout } from '@domain/checkout';
@@ -103,6 +90,7 @@ import { PrintConfiguration } from '@generated/swagger/print-api';
import { RemiConfiguration } from '@generated/swagger/inventory-api';
import { WwsConfiguration } from '@generated/swagger/wws-api';
import { UiIconModule } from '@ui/icon';
import { provideCoreTabs } from '@isa/core/tabs';
// --- Store Configuration ---
@@ -139,7 +127,7 @@ function appInitializerFactory(_config: Config, injector: Injector) {
return async () => {
const logger = loggerFactory(() => ({ service: 'AppInitializer' }));
const statusElement = document.querySelector('#init-status');
const laoderElement = document.querySelector('#init-loader');
const loaderElement = document.querySelector('#init-loader');
try {
logger.info('Starting application initialization');
@@ -221,14 +209,11 @@ function appInitializerFactory(_config: Config, injector: Injector) {
.subscribe((state) => {
userStorage.set('store', state);
});
logger.info('Application initialization completed');
injector.get(TabNavigationService).init();
} catch (error) {
logger.error('Application initialization failed', error as Error, () => ({
message: (error as Error).message,
}));
laoderElement.remove();
loaderElement?.remove();
statusElement.classList.add('text-xl');
statusElement.innerHTML +=
'⚡<br><br><b>Fehler bei der Initialisierung</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br><br>';
@@ -414,7 +399,7 @@ export const appConfig: ApplicationConfig = {
provideUserSubFactory(USER_SUB_FACTORY),
// Title strategy
{ provide: TitleStrategy, useClass: IsaTitleStrategy },
provideCoreTabs(),
// Import providers from NgModules
importProvidersFrom(

View File

@@ -1,4 +1,9 @@
import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
OnInit,
inject,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '@core/auth';
@@ -7,25 +12,24 @@ import { AuthService } from '@core/auth';
templateUrl: 'token-login.component.html',
styleUrls: ['token-login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
})
export class TokenLoginComponent implements OnInit {
constructor(
private _route: ActivatedRoute,
private _authService: AuthService,
private _router: Router,
) {}
readonly #route = inject(ActivatedRoute);
readonly #authService = inject(AuthService);
readonly #router = inject(Router);
ngOnInit() {
if (
this._route.snapshot.params.token &&
!this._authService.isAuthenticated()
this.#route.snapshot.params.token &&
!this.#authService.isAuthenticated()
) {
this._authService.setKeyCardToken(this._route.snapshot.params.token);
this._authService.login();
} else if (!this._authService.isAuthenticated()) {
this._authService.login();
} else if (this._authService.isAuthenticated()) {
this._router.navigate(['/']);
this.#authService.setKeyCardToken(this.#route.snapshot.params.token);
this.#authService.login();
} else if (!this.#authService.isAuthenticated()) {
this.#authService.login();
} else if (this.#authService.isAuthenticated()) {
this.#router.navigate(['/']);
}
}
}

View File

@@ -12,6 +12,7 @@ import {
OrderListItemDTO,
OrderService,
ReceiptService,
ReceiptType,
StatusValues,
StockStatusCodeService,
ValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO,
@@ -68,7 +69,10 @@ export class DomainOmsService {
return this.receiptService
.ReceiptGetReceiptsByOrderItemSubset({
payload: {
receiptType: 65 as unknown as any,
// 65 represents a combination of receipt type flags (1 + 64)
// that the backend accepts but is not part of the generated ReceiptType union.
// TODO: Update swagger spec to include combined flag values.
receiptType: 65 as ReceiptType,
ids: orderItemSubsetIds,
eagerLoading: 1,
},

View File

@@ -54,6 +54,8 @@ import {
NavigateAfterRewardSelection,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
import { toSignal } from '@angular/core/rxjs-interop';
import { useTabSubtitle } from '@isa/core/tabs';
@Component({
selector: 'page-article-details',
@@ -299,6 +301,10 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
),
);
processId = toSignal(this.applicationService.activatedProcessId$);
item = toSignal(this.store.item$);
constructor(
public readonly applicationService: ApplicationService,
private activatedRoute: ActivatedRoute,
@@ -316,7 +322,9 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
private _router: Router,
private _domainCheckoutService: DomainCheckoutService,
private _store: Store,
) {}
) {
useTabSubtitle(this.item, (item) => item?.product?.name);
}
ngOnInit() {
const processIdSubscription = this.activatedRoute.parent.params

View File

@@ -1,180 +1,228 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { ApplicationService } from '@core/application';
import { EnvironmentService } from '@core/environment';
import { Observable, Subject } from 'rxjs';
import { map, takeUntil, withLatestFrom } from 'rxjs/operators';
import { ArticleSearchService } from '../article-search.store';
import { ActivatedRoute } from '@angular/router';
import { ProductCatalogNavigationService } from '@shared/services/navigation';
import { Filter, FilterComponent } from '@shared/components/filter';
@Component({
selector: 'page-article-search-filter',
templateUrl: 'search-filter.component.html',
styleUrls: ['search-filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ArticleSearchFilterComponent implements OnInit, OnDestroy {
_processId$ = this._activatedRoute.parent.data.pipe(map((data) => Number(data.processId)));
fetching$: Observable<boolean> = this.articleSearch.fetching$;
filter$: Observable<Filter> = this.articleSearch.filter$;
searchboxHint$ = this.articleSearch.searchboxHint$;
@ViewChild(FilterComponent, { static: false })
uiFilterComponent: FilterComponent;
showFilter: boolean = false;
get isTablet() {
return this._environment.matchTablet();
}
get showFilterClose$() {
return this._environment.matchDesktopLarge$.pipe(map((matches) => !(matches && this.sideOutlet === 'search')));
}
get sideOutlet() {
return this._activatedRoute?.parent?.children?.find((childRoute) => childRoute?.outlet === 'side')?.snapshot
?.routeConfig?.path;
}
get primaryOutlet() {
return this._activatedRoute?.parent?.children?.find((childRoute) => childRoute?.outlet === 'primary')?.snapshot
?.routeConfig?.path;
}
get closeFilterRoute() {
const processId = Number(this._activatedRoute?.parent?.snapshot?.data?.processId);
const itemId = this._activatedRoute.snapshot.params.id;
if (!itemId) {
if (this.sideOutlet === 'search') {
return this._navigationService.getArticleSearchBasePath(processId).path;
} else if (this.primaryOutlet === 'results' || this.sideOutlet === 'results') {
return this._navigationService.getArticleSearchResultsPath(processId).path;
}
} else {
return this._navigationService.getArticleDetailsPath({ processId, itemId }).path;
}
}
private _onDestroy$ = new Subject<void>();
constructor(
private articleSearch: ArticleSearchService,
private _environment: EnvironmentService,
private _activatedRoute: ActivatedRoute,
public application: ApplicationService,
private _navigationService: ProductCatalogNavigationService,
) {}
ngOnInit() {
this.showFilter = this.sideOutlet !== 'search';
this._activatedRoute.queryParams
.pipe(takeUntil(this._onDestroy$))
.subscribe(async (queryParams) => await this.articleSearch.setDefaultFilter(queryParams));
// #4143 To make Splitscreen Search and Filter work combined
this.articleSearch.searchStarted.pipe(takeUntil(this._onDestroy$)).subscribe(async (_) => {
let queryParams = {
...this.articleSearch.filter.getQueryParams(),
...this.cleanupQueryParams(this.uiFilterComponent?.uiFilter?.getQueryParams()),
};
// Always override query if not in tablet mode
if (!!this.articleSearch.filter.getQueryParams()?.main_qs && !this.isTablet) {
queryParams = { ...queryParams, main_qs: this.articleSearch.filter.getQueryParams()?.main_qs };
}
await this.articleSearch.setDefaultFilter(queryParams);
});
this.articleSearch.searchCompleted
.pipe(takeUntil(this._onDestroy$), withLatestFrom(this._processId$))
.subscribe(async ([searchCompleted, processId]) => {
if (searchCompleted.state.searchState === '') {
const params = searchCompleted.state.filter.getQueryParams();
if (searchCompleted.state.hits === 1) {
const item = searchCompleted.state.items.find((f) => f);
await this._navigationService
.getArticleDetailsPath({
processId,
itemId: item.id,
extras: { queryParams: params },
})
.navigate();
} else {
await this._navigationService.getArticleSearchResultsPath(processId, { queryParams: params }).navigate();
}
}
});
}
ngOnDestroy(): void {
this._onDestroy$.next();
this._onDestroy$.complete();
}
applyFilter(value: Filter) {
this.uiFilterComponent?.cancelAutocomplete();
this.articleSearch.search({ clear: true });
this.articleSearch.searchCompleted
.pipe(takeUntil(this._onDestroy$), withLatestFrom(this._processId$))
.subscribe(async ([searchCompleted, processId]) => {
if (searchCompleted.state.searchState === '') {
const params = searchCompleted.state.filter.getQueryParams();
if (searchCompleted.state.hits === 1) {
const item = searchCompleted.state.items.find((f) => f);
await this._navigationService
.getArticleDetailsPath({
processId,
itemId: item.id,
extras: { queryParams: params },
})
.navigate();
} else {
await this._navigationService.getArticleSearchResultsPath(processId, { queryParams: params }).navigate();
}
}
});
}
clearFilter(value: Filter) {
value.unselectAllFilterOptions();
}
hasSelectedOptions(filter: Filter) {
// Is Query available
const hasInputOptions = !!filter.input.find(
(input) => !!input.input.find((fi) => fi.hasFilterInputOptionsSelected()),
);
// Are filter or filterChips selected
const hasFilterOptions = !!filter.filter.find(
(input) => !!input.input.find((fi) => fi.hasFilterInputOptionsSelected()),
);
return hasInputOptions || hasFilterOptions;
}
resetFilter(value: Filter) {
const queryParams = { main_qs: value?.getQueryParams()?.main_qs || '' };
this.articleSearch.setDefaultFilter(queryParams);
}
cleanupQueryParams(params: Record<string, string> = {}) {
const clean = { ...params };
for (const key in clean) {
if (Object.prototype.hasOwnProperty.call(clean, key)) {
if (clean[key] == undefined) {
delete clean[key];
}
}
}
return clean;
}
}
import {
ChangeDetectionStrategy,
Component,
computed,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { ApplicationService } from '@core/application';
import { EnvironmentService } from '@core/environment';
import { Observable, Subject } from 'rxjs';
import { map, takeUntil, withLatestFrom } from 'rxjs/operators';
import { ArticleSearchService } from '../article-search.store';
import { ActivatedRoute } from '@angular/router';
import { ProductCatalogNavigationService } from '@shared/services/navigation';
import { Filter, FilterComponent } from '@shared/components/filter';
import { toSignal } from '@angular/core/rxjs-interop';
import { useQueryParamSubtitle } from '@isa/core/tabs';
@Component({
selector: 'page-article-search-filter',
templateUrl: 'search-filter.component.html',
styleUrls: ['search-filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ArticleSearchFilterComponent implements OnInit, OnDestroy {
_processId$ = this._activatedRoute.parent.data.pipe(
map((data) => Number(data.processId)),
);
processId = toSignal(this._processId$);
fetching$: Observable<boolean> = this.articleSearch.fetching$;
filter$: Observable<Filter> = this.articleSearch.filter$;
filter = toSignal(this.filter$);
filterParams = computed(() => this.filter()?.getQueryParams());
searchboxHint$ = this.articleSearch.searchboxHint$;
@ViewChild(FilterComponent, { static: false })
uiFilterComponent: FilterComponent;
showFilter = false;
get isTablet() {
return this._environment.matchTablet();
}
get showFilterClose$() {
return this._environment.matchDesktopLarge$.pipe(
map((matches) => !(matches && this.sideOutlet === 'search')),
);
}
get sideOutlet() {
return this._activatedRoute?.parent?.children?.find(
(childRoute) => childRoute?.outlet === 'side',
)?.snapshot?.routeConfig?.path;
}
get primaryOutlet() {
return this._activatedRoute?.parent?.children?.find(
(childRoute) => childRoute?.outlet === 'primary',
)?.snapshot?.routeConfig?.path;
}
get closeFilterRoute() {
const processId = Number(
this._activatedRoute?.parent?.snapshot?.data?.processId,
);
const itemId = this._activatedRoute.snapshot.params.id;
if (!itemId) {
if (this.sideOutlet === 'search') {
return this._navigationService.getArticleSearchBasePath(processId).path;
} else if (
this.primaryOutlet === 'results' ||
this.sideOutlet === 'results'
) {
return this._navigationService.getArticleSearchResultsPath(processId)
.path;
}
} else {
return this._navigationService.getArticleDetailsPath({
processId,
itemId,
}).path;
}
}
private _onDestroy$ = new Subject<void>();
constructor(
private articleSearch: ArticleSearchService,
private _environment: EnvironmentService,
private _activatedRoute: ActivatedRoute,
public application: ApplicationService,
private _navigationService: ProductCatalogNavigationService,
) {
useQueryParamSubtitle(this.filterParams, 'main_qs', 'Filter');
}
ngOnInit() {
this.showFilter = this.sideOutlet !== 'search';
this._activatedRoute.queryParams
.pipe(takeUntil(this._onDestroy$))
.subscribe(
async (queryParams) =>
await this.articleSearch.setDefaultFilter(queryParams),
);
// #4143 To make Splitscreen Search and Filter work combined
this.articleSearch.searchStarted
.pipe(takeUntil(this._onDestroy$))
.subscribe(async (_) => {
let queryParams = {
...this.articleSearch.filter.getQueryParams(),
...this.cleanupQueryParams(
this.uiFilterComponent?.uiFilter?.getQueryParams(),
),
};
// Always override query if not in tablet mode
if (
!!this.articleSearch.filter.getQueryParams()?.main_qs &&
!this.isTablet
) {
queryParams = {
...queryParams,
main_qs: this.articleSearch.filter.getQueryParams()?.main_qs,
};
}
await this.articleSearch.setDefaultFilter(queryParams);
});
this.articleSearch.searchCompleted
.pipe(takeUntil(this._onDestroy$), withLatestFrom(this._processId$))
.subscribe(async ([searchCompleted, processId]) => {
if (searchCompleted.state.searchState === '') {
const params = searchCompleted.state.filter.getQueryParams();
if (searchCompleted.state.hits === 1) {
const item = searchCompleted.state.items.find((f) => f);
await this._navigationService
.getArticleDetailsPath({
processId,
itemId: item.id,
extras: { queryParams: params },
})
.navigate();
} else {
await this._navigationService
.getArticleSearchResultsPath(processId, { queryParams: params })
.navigate();
}
}
});
}
ngOnDestroy(): void {
this._onDestroy$.next();
this._onDestroy$.complete();
}
applyFilter(value: Filter) {
this.uiFilterComponent?.cancelAutocomplete();
this.articleSearch.search({ clear: true });
this.articleSearch.searchCompleted
.pipe(takeUntil(this._onDestroy$), withLatestFrom(this._processId$))
.subscribe(async ([searchCompleted, processId]) => {
if (searchCompleted.state.searchState === '') {
const params = searchCompleted.state.filter.getQueryParams();
if (searchCompleted.state.hits === 1) {
const item = searchCompleted.state.items.find((f) => f);
await this._navigationService
.getArticleDetailsPath({
processId,
itemId: item.id,
extras: { queryParams: params },
})
.navigate();
} else {
await this._navigationService
.getArticleSearchResultsPath(processId, { queryParams: params })
.navigate();
}
}
});
}
clearFilter(value: Filter) {
value.unselectAllFilterOptions();
}
hasSelectedOptions(filter: Filter) {
// Is Query available
const hasInputOptions = !!filter.input.find(
(input) => !!input.input.find((fi) => fi.hasFilterInputOptionsSelected()),
);
// Are filter or filterChips selected
const hasFilterOptions = !!filter.filter.find(
(input) => !!input.input.find((fi) => fi.hasFilterInputOptionsSelected()),
);
return hasInputOptions || hasFilterOptions;
}
resetFilter(value: Filter) {
const queryParams = { main_qs: value?.getQueryParams()?.main_qs || '' };
this.articleSearch.setDefaultFilter(queryParams);
}
cleanupQueryParams(params: Record<string, string> = {}) {
const clean = { ...params };
for (const key in clean) {
if (Object.prototype.hasOwnProperty.call(clean, key)) {
if (clean[key] == undefined) {
delete clean[key];
}
}
}
return clean;
}
}

View File

@@ -22,7 +22,7 @@ const routes: Routes = [
{
path: 'filter',
component: ArticleSearchFilterComponent,
title: 'Artikelsuche - Filter',
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche - Filter',
@@ -32,7 +32,7 @@ const routes: Routes = [
{
path: 'filter/:id',
component: ArticleSearchFilterComponent,
title: 'Artikelsuche - Filter',
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche - Filter',
@@ -59,7 +59,7 @@ const routes: Routes = [
{
path: 'results',
component: ArticleSearchResultsComponent,
title: 'Artikelsuche - Trefferliste',
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
@@ -70,7 +70,7 @@ const routes: Routes = [
path: 'results',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Trefferliste',
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
@@ -81,7 +81,7 @@ const routes: Routes = [
path: 'results/:id',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Artikeldetails',
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails',
@@ -92,7 +92,7 @@ const routes: Routes = [
path: 'results/:ean/ean',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Artikeldetails (EAN)',
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
@@ -102,7 +102,7 @@ const routes: Routes = [
{
path: 'details/:id',
component: ArticleDetailsComponent,
title: 'Artikelsuche - Artikeldetails (ID)',
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (ID)',
@@ -112,7 +112,7 @@ const routes: Routes = [
{
path: 'details/:ean/ean',
component: ArticleDetailsComponent,
title: 'Artikelsuche - Artikeldetails (EAN)',
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',

View File

@@ -1,187 +1,252 @@
import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core';
import {
emailNotificationValidator,
mobileNotificationValidator,
} from '@shared/components/notification-channel-control';
import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { combineLatest } from 'rxjs';
import { CheckoutReviewStore } from '../checkout-review.store';
import { first, map, switchMap, takeUntil, withLatestFrom } from 'rxjs/operators';
import { ApplicationService } from '@core/application';
import { DomainCheckoutService } from '@domain/checkout';
import { Router } from '@angular/router';
import { BuyerDTO, NotificationChannel } from '@generated/swagger/checkout-api';
import { CustomerSearchNavigation } from '@shared/services/navigation';
@Component({
selector: 'page-checkout-review-details',
templateUrl: 'checkout-review-details.component.html',
styleUrls: ['checkout-review-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class CheckoutReviewDetailsComponent implements OnInit {
customerNavigation = inject(CustomerSearchNavigation);
control: UntypedFormGroup;
customerFeatures$ = this._store.customerFeatures$;
payer$ = this._store.payer$;
buyer$ = this._store.buyer$;
showNotificationChannels$ = combineLatest([this._store.shoppingCartItems$, this.payer$, this.buyer$]).pipe(
takeUntil(this._store.orderCompleted),
map(
([items, payer, buyer]) =>
(!!payer || !!buyer) &&
items.some((item) => item.features?.orderType === 'Rücklage' || item.features?.orderType === 'Abholung'),
),
);
notificationChannel$ = this._application.activatedProcessId$.pipe(
takeUntil(this._store.orderCompleted),
switchMap((processId) => this._domainCheckoutService.getNotificationChannels({ processId })),
);
communicationDetails$ = this._application.activatedProcessId$.pipe(
takeUntil(this._store.orderCompleted),
switchMap((processId) => this._domainCheckoutService.getBuyerCommunicationDetails({ processId })),
map((communicationDetails) => communicationDetails ?? { email: undefined, mobile: undefined }),
);
specialComment$ = this._application.activatedProcessId$.pipe(
switchMap((processId) => this._domainCheckoutService.getSpecialComment({ processId })),
);
shippingAddress$ = this._application.activatedProcessId$.pipe(
takeUntil(this._store.orderCompleted),
switchMap((processId) => this._domainCheckoutService.getShippingAddress({ processId })),
);
showAddresses$ = this._store.shoppingCartItems$.pipe(
takeUntil(this._store.orderCompleted),
withLatestFrom(this.customerFeatures$, this.payer$, this.shippingAddress$),
map(([items, customerFeatures, payer, shippingAddress]) => {
const hasShippingOrBillingAddresses = !!payer?.address || !!shippingAddress;
const hasShippingFeature = items.some(
(item) =>
item.features?.orderType === 'Versand' ||
item.features?.orderType === 'B2B-Versand' ||
item.features?.orderType === 'DIG-Versand',
);
const isB2bCustomer = !!customerFeatures?.b2b;
return hasShippingOrBillingAddresses && (hasShippingFeature || isB2bCustomer);
}),
);
notificationChannelLoading$ = this._store.notificationChannelLoading$;
constructor(
private _fb: UntypedFormBuilder,
private _store: CheckoutReviewStore,
private _application: ApplicationService,
private _domainCheckoutService: DomainCheckoutService,
private _router: Router,
) {}
async ngOnInit() {
await this.initNotificationsControl();
}
async initNotificationsControl() {
const fb = this._fb;
const notificationChannel = await this.notificationChannel$.pipe(first()).toPromise();
const communicationDetails = await this.communicationDetails$.pipe(first()).toPromise();
let selectedNotificationChannel = 0;
if ((notificationChannel & 1) === 1 && communicationDetails.email) {
selectedNotificationChannel += 1;
}
if ((notificationChannel & 2) === 2 && communicationDetails.mobile) {
selectedNotificationChannel += 2;
}
// #1967 Wenn E-Mail und SMS als NotificationChannel gesetzt sind, nur E-Mail anhaken
if ((selectedNotificationChannel & 3) === 3) {
selectedNotificationChannel = 1;
}
if (!this._store.notificationsControl) {
this.control = fb.group({
notificationChannel: new UntypedFormGroup({
selected: new UntypedFormControl(selectedNotificationChannel),
email: new UntypedFormControl(
communicationDetails ? communicationDetails.email : '',
emailNotificationValidator,
),
mobile: new UntypedFormControl(
communicationDetails ? communicationDetails.mobile : '',
mobileNotificationValidator,
),
}),
});
this._store.notificationsControl = this.control;
} else {
this.control = this._store.notificationsControl;
}
}
setAgentComment(agentComment: string) {
this._domainCheckoutService.setSpecialComment({ processId: this._application.activatedProcessId, agentComment });
}
updateNotifications(notificationChannels?: NotificationChannel[]) {
this._store.onNotificationChange(notificationChannels);
}
getNameFromBuyer(buyer: BuyerDTO): { value: string; label: string } {
if (buyer?.lastName && buyer?.firstName) {
return { value: `${buyer?.lastName}, ${buyer?.firstName}`, label: 'Nachname, Vorname' };
} else if (buyer?.lastName) {
return { value: buyer?.lastName, label: 'Nachname, Vorname' };
} else if (buyer?.firstName) {
return { value: buyer?.firstName, label: 'Nachname, Vorname' };
} else if (buyer?.organisation?.name) {
return { value: buyer?.organisation?.name, label: 'Firmenname' };
} else {
return;
}
}
async changeAddress() {
const processId = this._application.activatedProcessId;
const customer = await this._domainCheckoutService.getBuyer({ processId }).pipe(first()).toPromise();
if (!customer) {
this.navigateToCustomerSearch(processId);
return;
}
const customerId = customer.source;
await this.customerNavigation.navigateToDetails({
processId,
customerId,
customer: { customerNumber: customer.buyerNumber },
});
// this._router.navigate(['/kunde', this._application.activatedProcessId, 'customer', 'search', `${customerId}`]);
}
async navigateToCustomerSearch(processId: number) {
try {
const response = await this.customerFeatures$
.pipe(
first(),
switchMap((customerFeatures) => {
return this._domainCheckoutService.canSetCustomer({ processId, customerFeatures });
}),
)
.toPromise();
this._router.navigate(['/kunde', this._application.activatedProcessId, 'customer', 'search'], {
queryParams: { filter_customertype: response.filter.customertype },
});
} catch (error) {
this._router.navigate(['/kunde', this._application.activatedProcessId, 'customer', 'search']);
}
}
}
import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core';
import {
emailNotificationValidator,
mobileNotificationValidator,
} from '@shared/components/notification-channel-control';
import {
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
} from '@angular/forms';
import { combineLatest } from 'rxjs';
import { CheckoutReviewStore } from '../checkout-review.store';
import {
first,
map,
switchMap,
takeUntil,
withLatestFrom,
} from 'rxjs/operators';
import { ApplicationService } from '@core/application';
import { DomainCheckoutService } from '@domain/checkout';
import { Router } from '@angular/router';
import { BuyerDTO, NotificationChannel } from '@generated/swagger/checkout-api';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { toSignal } from '@angular/core/rxjs-interop';
import { useTabSubtitle } from '@isa/core/tabs';
import { getCustomerName } from '@isa/crm/data-access';
@Component({
selector: 'page-checkout-review-details',
templateUrl: 'checkout-review-details.component.html',
styleUrls: ['checkout-review-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class CheckoutReviewDetailsComponent implements OnInit {
customerNavigation = inject(CustomerSearchNavigation);
control: UntypedFormGroup;
customerFeatures$ = this._store.customerFeatures$;
payer$ = this._store.payer$;
buyer$ = this._store.buyer$;
showNotificationChannels$ = combineLatest([
this._store.shoppingCartItems$,
this.payer$,
this.buyer$,
]).pipe(
takeUntil(this._store.orderCompleted),
map(
([items, payer, buyer]) =>
(!!payer || !!buyer) &&
items.some(
(item) =>
item.features?.orderType === 'Rücklage' ||
item.features?.orderType === 'Abholung',
),
),
);
notificationChannel$ = this._application.activatedProcessId$.pipe(
takeUntil(this._store.orderCompleted),
switchMap((processId) =>
this._domainCheckoutService.getNotificationChannels({ processId }),
),
);
communicationDetails$ = this._application.activatedProcessId$.pipe(
takeUntil(this._store.orderCompleted),
switchMap((processId) =>
this._domainCheckoutService.getBuyerCommunicationDetails({ processId }),
),
map(
(communicationDetails) =>
communicationDetails ?? { email: undefined, mobile: undefined },
),
);
specialComment$ = this._application.activatedProcessId$.pipe(
switchMap((processId) =>
this._domainCheckoutService.getSpecialComment({ processId }),
),
);
shippingAddress$ = this._application.activatedProcessId$.pipe(
takeUntil(this._store.orderCompleted),
switchMap((processId) =>
this._domainCheckoutService.getShippingAddress({ processId }),
),
);
showAddresses$ = this._store.shoppingCartItems$.pipe(
takeUntil(this._store.orderCompleted),
withLatestFrom(this.customerFeatures$, this.payer$, this.shippingAddress$),
map(([items, customerFeatures, payer, shippingAddress]) => {
const hasShippingOrBillingAddresses =
!!payer?.address || !!shippingAddress;
const hasShippingFeature = items.some(
(item) =>
item.features?.orderType === 'Versand' ||
item.features?.orderType === 'B2B-Versand' ||
item.features?.orderType === 'DIG-Versand',
);
const isB2bCustomer = !!customerFeatures?.b2b;
return (
hasShippingOrBillingAddresses && (hasShippingFeature || isB2bCustomer)
);
}),
);
notificationChannelLoading$ = this._store.notificationChannelLoading$;
processId = toSignal(this._application.activatedProcessId$);
buyer = toSignal(this.buyer$);
constructor(
private _fb: UntypedFormBuilder,
private _store: CheckoutReviewStore,
private _application: ApplicationService,
private _domainCheckoutService: DomainCheckoutService,
private _router: Router,
) {
useTabSubtitle(this.buyer, getCustomerName);
}
async ngOnInit() {
await this.initNotificationsControl();
}
async initNotificationsControl() {
const fb = this._fb;
const notificationChannel = await this.notificationChannel$
.pipe(first())
.toPromise();
const communicationDetails = await this.communicationDetails$
.pipe(first())
.toPromise();
let selectedNotificationChannel = 0;
if ((notificationChannel & 1) === 1 && communicationDetails.email) {
selectedNotificationChannel += 1;
}
if ((notificationChannel & 2) === 2 && communicationDetails.mobile) {
selectedNotificationChannel += 2;
}
// #1967 Wenn E-Mail und SMS als NotificationChannel gesetzt sind, nur E-Mail anhaken
if ((selectedNotificationChannel & 3) === 3) {
selectedNotificationChannel = 1;
}
if (!this._store.notificationsControl) {
this.control = fb.group({
notificationChannel: new UntypedFormGroup({
selected: new UntypedFormControl(selectedNotificationChannel),
email: new UntypedFormControl(
communicationDetails ? communicationDetails.email : '',
emailNotificationValidator,
),
mobile: new UntypedFormControl(
communicationDetails ? communicationDetails.mobile : '',
mobileNotificationValidator,
),
}),
});
this._store.notificationsControl = this.control;
} else {
this.control = this._store.notificationsControl;
}
}
setAgentComment(agentComment: string) {
this._domainCheckoutService.setSpecialComment({
processId: this._application.activatedProcessId,
agentComment,
});
}
updateNotifications(notificationChannels?: NotificationChannel[]) {
this._store.onNotificationChange(notificationChannels);
}
getNameFromBuyer(buyer: BuyerDTO): { value: string; label: string } {
if (buyer?.lastName && buyer?.firstName) {
return {
value: `${buyer?.lastName}, ${buyer?.firstName}`,
label: 'Nachname, Vorname',
};
} else if (buyer?.lastName) {
return { value: buyer?.lastName, label: 'Nachname, Vorname' };
} else if (buyer?.firstName) {
return { value: buyer?.firstName, label: 'Nachname, Vorname' };
} else if (buyer?.organisation?.name) {
return { value: buyer?.organisation?.name, label: 'Firmenname' };
} else {
return;
}
}
async changeAddress() {
const processId = this._application.activatedProcessId;
const customer = await this._domainCheckoutService
.getBuyer({ processId })
.pipe(first())
.toPromise();
if (!customer) {
this.navigateToCustomerSearch(processId);
return;
}
const customerId = customer.source;
await this.customerNavigation.navigateToDetails({
processId,
customerId,
customer: { customerNumber: customer.buyerNumber },
});
// this._router.navigate(['/kunde', this._application.activatedProcessId, 'customer', 'search', `${customerId}`]);
}
async navigateToCustomerSearch(processId: number) {
try {
const response = await this.customerFeatures$
.pipe(
first(),
switchMap((customerFeatures) => {
return this._domainCheckoutService.canSetCustomer({
processId,
customerFeatures,
});
}),
)
.toPromise();
this._router.navigate(
['/kunde', this._application.activatedProcessId, 'customer', 'search'],
{
queryParams: { filter_customertype: response.filter.customertype },
},
);
} catch (error) {
this._router.navigate([
'/kunde',
this._application.activatedProcessId,
'customer',
'search',
]);
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,46 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CheckoutReviewComponent } from './checkout-review/checkout-review.component';
import { CheckoutSummaryComponent } from './checkout-summary/checkout-summary.component';
import { PageCheckoutComponent } from './page-checkout.component';
import { CheckoutReviewDetailsComponent } from './checkout-review/details/checkout-review-details.component';
import { canDeactivateTabCleanup } from '@isa/core/tabs';
const routes: Routes = [
{
path: '',
component: PageCheckoutComponent,
children: [
{
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' },
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class PageCheckoutRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CheckoutReviewComponent } from './checkout-review/checkout-review.component';
import { CheckoutSummaryComponent } from './checkout-summary/checkout-summary.component';
import { PageCheckoutComponent } from './page-checkout.component';
import { CheckoutReviewDetailsComponent } from './checkout-review/details/checkout-review-details.component';
import { canDeactivateTabCleanup } from '@isa/core/tabs';
const routes: Routes = [
{
path: '',
component: PageCheckoutComponent,
children: [
{
path: 'details',
component: CheckoutReviewDetailsComponent,
title: 'Warenkorb',
outlet: 'side',
},
{
path: 'review',
component: CheckoutReviewComponent,
title: 'Warenkorb',
},
{
path: 'summary',
component: CheckoutSummaryComponent,
title: 'Bestellbestätigung',
canDeactivate: [canDeactivateTabCleanup],
},
{
path: 'summary/:orderIds',
component: CheckoutSummaryComponent,
title: 'Bestellbestätigung',
canDeactivate: [canDeactivateTabCleanup],
},
{ path: '', pathMatch: 'full', redirectTo: 'review' },
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class PageCheckoutRoutingModule {}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ import {
CrmTabMetadataService,
Customer,
AssignedPayer,
getCustomerName,
} from '@isa/crm/data-access';
import {
CustomerAdapter,
@@ -49,7 +50,7 @@ import {
NavigateAfterRewardSelection,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
import { TabService } from '@isa/core/tabs';
import { TabService, useTabSubtitle } from '@isa/core/tabs';
import { ShippingAddressDTO as CrmShippingAddressDTO } from '@generated/swagger/crm-api';
interface SelectCustomerContext {
@@ -112,9 +113,8 @@ export class CustomerDetailsViewMainComponent
}
checkHasReturnUrl(): void {
const hasContext = !!this._tabService.activatedTab()?.metadata?.[
'select-customer'
];
const hasContext =
!!this._tabService.activatedTab()?.metadata?.['select-customer'];
this.hasReturnUrl.set(hasContext);
}
@@ -302,6 +302,8 @@ export class CustomerDetailsViewMainComponent
this.hasKundenkarte$,
]).pipe(map(([type, hasCard]) => type === 'webshop' || hasCard));
customerSignal = toSignal(this._store.customer$);
constructor() {
super({
isBusy: false,
@@ -309,6 +311,8 @@ export class CustomerDetailsViewMainComponent
shippingAddress: undefined,
payer: undefined,
});
useTabSubtitle(this.customerSignal, getCustomerName);
}
setIsBusy(isBusy: boolean) {

View File

@@ -1,77 +1,118 @@
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { CustomerSearchStore } from '../store';
import { Filter, FilterModule } from '@shared/components/filter';
import { map } from 'rxjs/operators';
import { AsyncPipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { IconComponent } from '@shared/components/icon';
import { combineLatest } from 'rxjs';
import { CustomerSearchNavigation, CustomerCreateNavigation } from '@shared/services/navigation';
import { CustomerFilterMainViewModule } from '../filter-main-view/filter-main-view.module';
import { isEmpty } from 'lodash';
import { CustomerInfoDTO } from '@generated/swagger/crm-api';
@Component({
selector: 'page-customer-main-view',
templateUrl: 'main-view.component.html',
styleUrls: ['main-view.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-customer-main-view' },
imports: [AsyncPipe, RouterLink, FilterModule, IconComponent, CustomerFilterMainViewModule],
})
export class CustomerMainViewComponent {
private _searchNavigation = inject(CustomerSearchNavigation);
private _customerCreateNavigation = inject(CustomerCreateNavigation);
private _store = inject(CustomerSearchStore);
private _router = inject(Router);
filterRoute$ = combineLatest([this._store.processId$, this._store.filter$]).pipe(
map(([processId, filter]) => {
const route = this._searchNavigation.filterRoute({ processId, comingFrom: this._router.url?.split('?')[0] });
route.queryParams = { ...route.queryParams, ...filter?.getQueryParams() };
route.urlTree.queryParams = { ...route.urlTree.queryParams, ...filter?.getQueryParams() };
return route;
}),
);
createRoute$ = combineLatest(this._store.filter$, this._store.processId$).pipe(
map(([filter, processId]) => {
const queryParams = filter?.getQueryParams();
let customerInfo: CustomerInfoDTO;
if (queryParams?.main_qs) {
const isMail = queryParams.main_qs.includes('@');
customerInfo = {
lastName: !isMail ? queryParams.main_qs : undefined,
communicationDetails: isMail
? {
email: queryParams.main_qs,
}
: undefined,
};
}
return this._customerCreateNavigation.createCustomerRoute({ processId, customerInfo });
}),
);
filter$ = this._store.filter$;
hasFilter$ = this.filter$.pipe(
map((filter) => {
if (!filter) return false;
const qt = filter.getQueryToken();
return !isEmpty(qt.filter);
}),
);
fetching$ = this._store.fetchingCustomerList$;
message$ = this._store.message$;
search(filter: Filter) {
this._store.setFilter(filter);
this._store.search({ resetScrollIndex: true, ignoreRestore: true });
}
}
import {
Component,
ChangeDetectionStrategy,
inject,
computed,
} from '@angular/core';
import { CustomerSearchStore } from '../store';
import { Filter, FilterModule } from '@shared/components/filter';
import { map } from 'rxjs/operators';
import { AsyncPipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { IconComponent } from '@shared/components/icon';
import { combineLatest } from 'rxjs';
import {
CustomerSearchNavigation,
CustomerCreateNavigation,
} from '@shared/services/navigation';
import { CustomerFilterMainViewModule } from '../filter-main-view/filter-main-view.module';
import { isEmpty } from 'lodash';
import { CustomerInfoDTO } from '@generated/swagger/crm-api';
import { toSignal } from '@angular/core/rxjs-interop';
import { useQueryParamSubtitle } from '@isa/core/tabs';
@Component({
selector: 'page-customer-main-view',
templateUrl: 'main-view.component.html',
styleUrls: ['main-view.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-customer-main-view' },
imports: [
AsyncPipe,
RouterLink,
FilterModule,
IconComponent,
CustomerFilterMainViewModule,
],
})
export class CustomerMainViewComponent {
private _searchNavigation = inject(CustomerSearchNavigation);
private _customerCreateNavigation = inject(CustomerCreateNavigation);
private _store = inject(CustomerSearchStore);
private _router = inject(Router);
filterRoute$ = combineLatest([
this._store.processId$,
this._store.filter$,
]).pipe(
map(([processId, filter]) => {
const route = this._searchNavigation.filterRoute({
processId,
comingFrom: this._router.url?.split('?')[0],
});
route.queryParams = { ...route.queryParams, ...filter?.getQueryParams() };
route.urlTree.queryParams = {
...route.urlTree.queryParams,
...filter?.getQueryParams(),
};
return route;
}),
);
createRoute$ = combineLatest(
this._store.filter$,
this._store.processId$,
).pipe(
map(([filter, processId]) => {
const queryParams = filter?.getQueryParams();
let customerInfo: CustomerInfoDTO;
if (queryParams?.main_qs) {
const isMail = queryParams.main_qs.includes('@');
customerInfo = {
lastName: !isMail ? queryParams.main_qs : undefined,
communicationDetails: isMail
? {
email: queryParams.main_qs,
}
: undefined,
};
}
return this._customerCreateNavigation.createCustomerRoute({
processId,
customerInfo,
});
}),
);
filter$ = this._store.filter$;
hasFilter$ = this.filter$.pipe(
map((filter) => {
if (!filter) return false;
const qt = filter.getQueryToken();
return !isEmpty(qt.filter);
}),
);
fetching$ = this._store.fetchingCustomerList$;
message$ = this._store.message$;
processId = toSignal(this._store.processId$);
filter = toSignal(this.filter$);
filterParams = computed(() => this.filter()?.getQueryParams());
constructor() {
useQueryParamSubtitle(this.filterParams, 'main_qs', 'Kundensuche');
}
search(filter: Filter) {
this._store.setFilter(filter);
this._store.search({ resetScrollIndex: true, ignoreRestore: true });
}
}

View File

@@ -1,129 +1,165 @@
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
AfterContentInit,
ViewChild,
inject,
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { CustomerSearchStore } from '../store/customer-search.store';
import { BehaviorSubject, Subject, Subscription, combineLatest, race } from 'rxjs';
import { delay, filter, map, take, takeUntil } from 'rxjs/operators';
import { CustomerSearchNavigation, NavigationRoute } from '@shared/services/navigation';
import { isEmpty } from 'lodash';
import { Filter } from '@shared/components/filter';
import { CustomerResultListComponent } from '../../components/customer-result-list/customer-result-list.component';
import { EnvironmentService } from '@core/environment';
import { injectCancelSearch } from '@shared/services/cancel-subject';
@Component({
selector: 'page-customer-results-main-view',
templateUrl: 'results-main-view.component.html',
styleUrls: ['results-main-view.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class CustomerResultsMainViewComponent implements OnInit, OnDestroy, AfterContentInit {
private _store = inject(CustomerSearchStore);
private _router = inject(Router);
private _navigation = inject(CustomerSearchNavigation);
private _environment = inject(EnvironmentService);
cancelSearch = injectCancelSearch();
processId$ = this._store.processId$;
currentUrl$ = new BehaviorSubject<string>(this._router.url);
filterRoute$ = combineLatest([this.processId$, this.currentUrl$]).pipe(
map(([processId, url]) => {
const route = this._navigation.filterRoute({ processId, comingFrom: url?.split('?')[0] });
const routeTree = this._router.createUrlTree(route.path, { queryParams: route.queryParams });
const currentlyActive = this._router.isActive(routeTree, {
fragment: 'ignored',
matrixParams: 'ignored',
paths: 'exact',
queryParams: 'ignored',
});
if (currentlyActive) {
const urlTree = this._router.parseUrl(url);
const comingFrom = urlTree.queryParamMap.get('comingFrom');
return { path: [comingFrom], urlTree } as NavigationRoute;
}
return route;
}),
);
routerEventsSubscription: Subscription;
filter$ = this._store.filter$;
hasFilter$ = this.filter$.pipe(
map((filter) => {
if (!filter) return false;
const qt = filter.getQueryToken();
return !isEmpty(qt.filter);
}),
);
fetching$ = this._store.fetchingCustomerList$;
hits$ = this._store.customerListCount$;
customers$ = this._store.customerList$;
@ViewChild(CustomerResultListComponent, { static: true }) customerResultListComponent: CustomerResultListComponent;
isTablet$ = this._environment.matchTablet$;
isDesktopSmall$ = this._environment.matchDesktopSmall$;
message$ = this._store.message$;
private _onDestroy$ = new Subject<void>();
ngOnInit(): void {
this.routerEventsSubscription = this._router.events.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.currentUrl$.next(event.url);
}
});
}
ngOnDestroy(): void {
this.routerEventsSubscription?.unsubscribe();
this._onDestroy$.next();
this._onDestroy$.complete();
}
ngAfterContentInit(): void {
const scrollIndex = this._store.restoreScrollIndex();
if (typeof scrollIndex === 'number') {
const hasCustomerList$ = this._store.customerList$.pipe(filter((customers) => customers?.length > 0));
race(hasCustomerList$, this._store.customerListRestored$, this._store.customerListResponse$)
.pipe(takeUntil(this._onDestroy$), delay(100), take(1))
.subscribe(() => {
this.customerResultListComponent.scrollToIndex(Number(scrollIndex));
});
}
}
search(filter: Filter) {
this._store.setFilter(filter);
this._store.search({ resetScrollIndex: true, ignoreRestore: true });
}
paginate() {
this._store.paginate();
}
scrollIndexChange(index: number) {
this._store.storeScrollIndex(index);
}
}
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
AfterContentInit,
ViewChild,
inject,
computed,
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { CustomerSearchStore } from '../store/customer-search.store';
import {
BehaviorSubject,
Subject,
Subscription,
combineLatest,
race,
} from 'rxjs';
import { delay, filter, map, take, takeUntil } from 'rxjs/operators';
import {
CustomerSearchNavigation,
NavigationRoute,
} from '@shared/services/navigation';
import { isEmpty } from 'lodash';
import { Filter } from '@shared/components/filter';
import { CustomerResultListComponent } from '../../components/customer-result-list/customer-result-list.component';
import { EnvironmentService } from '@core/environment';
import { injectCancelSearch } from '@shared/services/cancel-subject';
import { toSignal } from '@angular/core/rxjs-interop';
import { useQueryParamSubtitle } from '@isa/core/tabs';
@Component({
selector: 'page-customer-results-main-view',
templateUrl: 'results-main-view.component.html',
styleUrls: ['results-main-view.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class CustomerResultsMainViewComponent
implements OnInit, OnDestroy, AfterContentInit
{
private _store = inject(CustomerSearchStore);
private _router = inject(Router);
private _navigation = inject(CustomerSearchNavigation);
private _environment = inject(EnvironmentService);
cancelSearch = injectCancelSearch();
processId$ = this._store.processId$;
currentUrl$ = new BehaviorSubject<string>(this._router.url);
filterRoute$ = combineLatest([this.processId$, this.currentUrl$]).pipe(
map(([processId, url]) => {
const route = this._navigation.filterRoute({
processId,
comingFrom: url?.split('?')[0],
});
const routeTree = this._router.createUrlTree(route.path, {
queryParams: route.queryParams,
});
const currentlyActive = this._router.isActive(routeTree, {
fragment: 'ignored',
matrixParams: 'ignored',
paths: 'exact',
queryParams: 'ignored',
});
if (currentlyActive) {
const urlTree = this._router.parseUrl(url);
const comingFrom = urlTree.queryParamMap.get('comingFrom');
return { path: [comingFrom], urlTree } as NavigationRoute;
}
return route;
}),
);
routerEventsSubscription: Subscription;
filter$ = this._store.filter$;
hasFilter$ = this.filter$.pipe(
map((filter) => {
if (!filter) return false;
const qt = filter.getQueryToken();
return !isEmpty(qt.filter);
}),
);
fetching$ = this._store.fetchingCustomerList$;
hits$ = this._store.customerListCount$;
customers$ = this._store.customerList$;
@ViewChild(CustomerResultListComponent, { static: true })
customerResultListComponent: CustomerResultListComponent;
isTablet$ = this._environment.matchTablet$;
isDesktopSmall$ = this._environment.matchDesktopSmall$;
message$ = this._store.message$;
private _onDestroy$ = new Subject<void>();
processId = toSignal(this.processId$);
filter = toSignal(this.filter$);
filterParams = computed(() => this.filter()?.getQueryParams());
constructor() {
useQueryParamSubtitle(this.filterParams, 'main_qs', 'Kundensuche');
}
ngOnInit(): void {
this.routerEventsSubscription = this._router.events.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.currentUrl$.next(event.url);
}
});
}
ngOnDestroy(): void {
this.routerEventsSubscription?.unsubscribe();
this._onDestroy$.next();
this._onDestroy$.complete();
}
ngAfterContentInit(): void {
const scrollIndex = this._store.restoreScrollIndex();
if (typeof scrollIndex === 'number') {
const hasCustomerList$ = this._store.customerList$.pipe(
filter((customers) => customers?.length > 0),
);
race(
hasCustomerList$,
this._store.customerListRestored$,
this._store.customerListResponse$,
)
.pipe(takeUntil(this._onDestroy$), delay(100), take(1))
.subscribe(() => {
this.customerResultListComponent.scrollToIndex(Number(scrollIndex));
});
}
}
search(filter: Filter) {
this._store.setFilter(filter);
this._store.search({ resetScrollIndex: true, ignoreRestore: true });
}
paginate() {
this._store.paginate();
}
scrollIndexChange(index: number) {
this._store.storeScrollIndex(index);
}
}

View File

@@ -43,19 +43,19 @@ export const routes: Routes = [
{
path: 'search',
component: CustomerMainViewComponent,
title: 'Kundensuche',
title: 'Kunden',
data: { side: 'main', breadcrumb: 'main' },
},
{
path: 'search/list',
component: CustomerResultsMainViewComponent,
title: 'Kundensuche - Trefferliste',
title: 'Kundensuche',
data: { breadcrumb: 'search' },
},
{
path: 'search/filter',
component: CustomerFilterMainViewComponent,
title: 'Kundensuche - Filter',
title: 'Kundensuche',
data: { side: 'results', breadcrumb: 'filter' },
},
{
@@ -161,9 +161,13 @@ export const routes: Routes = [
component: CreateCustomerComponent,
canActivate: [CustomerCreateGuard],
canActivateChild: [CustomerCreateGuard],
title: 'Kundendaten erfassen',
title: 'Kunden erfassen',
children: [
{ path: 'create', component: CreateStoreCustomerComponent },
{
path: 'create',
component: CreateStoreCustomerComponent,
title: 'Kunden',
},
{ path: 'create/store', component: CreateStoreCustomerComponent },
{ path: 'create/webshop', component: CreateWebshopCustomerComponent },
{ path: 'create/b2b', component: CreateB2BCustomerComponent },

View File

@@ -1,600 +0,0 @@
# @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: `
<input [(ngModel)]="searchTerm" placeholder="Search..." />
<h1>{{ pageTitle().title }}</h1>
`
})
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: `<h1>{{ productName() || 'Loading...' }}</h1>`
})
export class ProductDetailsComponent implements OnInit {
private route = inject(ActivatedRoute);
productName = signal<string | null>(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: `<div>Step {{ currentStep() }} of 3</div>`
})
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: `<router-outlet />`
})
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: `<h1>Settings</h1>`
})
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: `
<h1>Order {{ orderId() }}</h1>
<p>Status: {{ orderStatus() }}</p>
`
})
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<string> =
(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<PageTitleInput>` - 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<string | null>(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.

View File

@@ -1,34 +0,0 @@
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: {},
},
];

View File

@@ -1,20 +0,0 @@
{
"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"
}
}
}

View File

@@ -1,3 +0,0 @@
export * from './lib/isa-title.strategy';
export * from './lib/use-page-title.function';
export * from './lib/title-management.types';

View File

@@ -1,157 +0,0 @@
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<typeof vi.fn>; getTitle: ReturnType<typeof vi.fn> };
let tabServiceMock: {
activatedTabId: ReturnType<typeof signal<number | null>>;
patchTab: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Arrange - Create mocks
titleServiceMock = {
setTitle: vi.fn(),
getTitle: vi.fn().mockReturnValue(''),
};
tabServiceMock = {
activatedTabId: signal<number | null>(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();
});
});
});

View File

@@ -1,29 +0,0 @@
/**
* 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;
}

View File

@@ -1,14 +0,0 @@
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'));
},
},
);

View File

@@ -1,255 +0,0 @@
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();
});
});
});

View File

@@ -1,79 +0,0 @@
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<symbol, () => 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();
}
}
}

View File

@@ -1,746 +0,0 @@
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<typeof vi.fn>; getTitle: ReturnType<typeof vi.fn> };
let tabServiceMock: {
activatedTabId: ReturnType<typeof signal<number | null>>;
patchTab: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Arrange - Create mocks
titleServiceMock = {
setTitle: vi.fn(),
getTitle: vi.fn().mockReturnValue(''),
};
tabServiceMock = {
activatedTabId: signal<number | null>(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: '<div>Test</div>',
})
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: '<div>Test</div>',
})
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: '<div>Test</div>',
})
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: '<div>Test</div>',
})
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: '<div>Test</div>',
})
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: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
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: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
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: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
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: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
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: '<div>Grandparent</div>',
})
class GrandparentComponent {
pageTitle = signal({ title: 'Main' });
constructor() {
usePageTitle(this.pageTitle);
}
}
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
@Component({
standalone: true,
template: '<div>Child</div>',
})
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: '<div>Test</div>',
})
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: '<div>Test</div>',
})
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: '<div>Test</div>',
})
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: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard', subtitle: 'Main' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
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: '<div>Test</div>',
})
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: '<div>Test</div>',
})
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: '<div>Test</div>',
})
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: '<div>Test</div>',
})
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: '<div>Test</div>',
})
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: '<div>Test</div>',
})
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',
});
});
});
});

View File

@@ -1,203 +0,0 @@
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: `<h1>Dashboard</h1>`
* })
* 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: `<input [(ngModel)]="searchTerm" />`
* })
* 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: `<h1>Order Details</h1>`
* })
* 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: `<h1>{{ productName() || 'Loading...' }}</h1>`
* })
* export class ProductDetailsComponent {
* productName = signal<string | null>(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<PageTitleInput>
): 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);
});
}

View File

@@ -1,13 +0,0 @@
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(),
);

View File

@@ -1,30 +0,0 @@
{
"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"
}
]
}

View File

@@ -1,27 +0,0 @@
{
"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"]
}

View File

@@ -1,29 +0,0 @@
{
"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"]
}

View File

@@ -1,33 +0,0 @@
/// <reference types='vitest' />
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'],
},
},
}));

View File

@@ -463,9 +463,9 @@ if (nextLocation) {
}
```
##### `getCurrentLocation(id: number): TabLocation | null`
##### `getCurrentLocationWithValidation(id: number): TabLocation | null`
Gets current location with automatic index validation.
Gets current location with automatic index validation and correction.
**Parameters:**
- `id: number` - Tab ID to query
@@ -473,12 +473,13 @@ Gets current location with automatic index validation.
**Returns:** Current location, or null if none or invalid
**Side Effects:**
- Automatically corrects invalid history indices
- Logs warnings if index correction occurs (when enabled)
- Automatically corrects invalid history indices (when `config.enableIndexValidation` is true)
- Logs warnings if index correction occurs
- Triggers storage autosave when corrections are made
**Example:**
```typescript
const currentLoc = this.#tabService.getCurrentLocation(42);
const currentLoc = this.#tabService.getCurrentLocationWithValidation(42);
if (currentLoc) {
console.log(`Current page: ${currentLoc.title}`);
}
@@ -595,6 +596,52 @@ export class MyComponent {
}
```
#### `useTabSubtitle<T>(source, transform, fallback?): void`
Sets up reactive tab subtitle management. Creates an effect that automatically updates the active tab's subtitle when the source signal changes.
**Parameters:**
- `source: Signal<T>` - Signal containing the data to derive subtitle from
- `transform: (value: T) => string | undefined | null` - Function to extract subtitle
- `fallback?: string` - Optional fallback when transform returns undefined/null
**Example:**
```typescript
export class CustomerDetailsComponent {
customerSignal = toSignal(this._store.customer$);
constructor() {
// Automatically updates tab subtitle when customer changes
useTabSubtitle(
this.customerSignal,
customer => getCustomerName(customer),
'Kundendetails'
);
}
}
```
#### `useQueryParamSubtitle(queryParamsSource, key?, fallback?): void`
Specialized helper for search components. Extracts subtitle from a query params object.
**Parameters:**
- `queryParamsSource: Signal<Record<string, string> | undefined | null>` - Signal with query params
- `key?: string` - Key to extract (default: 'main_qs')
- `fallback?: string` - Fallback string (default: 'Suche')
**Example:**
```typescript
export class CustomerSearchComponent {
filter = toSignal(this._store.filter$);
filterParams = computed(() => this.filter()?.getQueryParams());
constructor() {
useQueryParamSubtitle(this.filterParams, 'main_qs', 'Kundensuche');
}
}
```
#### `getTabHelper(tabId: number, entities: Record<number, Tab>): Tab | undefined`
Retrieves a tab from entity map.
@@ -1502,7 +1549,7 @@ withStorage('tabs', UserStorageProvider, { autosave: true })
The system maintains history index integrity through:
1. **Validation on read** - `getCurrentLocation()` validates indices
1. **Validation on read** - `getCurrentLocationWithValidation()` validates indices
2. **Validation on navigation** - Back/forward check bounds
3. **Correction on errors** - Invalid indices auto-corrected
4. **Logging** - Optional warnings for debugging

View File

@@ -1,12 +1,9 @@
export * from './lib/navigate-back-button.component';
export * from './lib/tab.injector';
export * from './lib/tab.resolver-fn';
export * from './lib/schemas';
export * from './lib/tab';
export * from './lib/tab-navigation.service';
export * from './lib/tab-navigation.constants';
export * from './lib/tab-config';
export * from './lib/components';
export * from './lib/guards';
export * from './lib/resolvers';
export * from './lib/provider';
export * from './lib/helpers';
export * from './lib/has-tab-id.guard';
export * from './lib/tab-cleanup.guard';
export * from './lib/deactivate-tab.guard';
export * from './lib/tab.service';
export * from './lib/tab-config';
export * from './lib/schemas';
export * from './lib/tab.injector';

View File

@@ -0,0 +1 @@
export * from './navigate-back-button.component';

View File

@@ -2,7 +2,7 @@ import { Component, inject, computed, input } from '@angular/core';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft } from '@isa/icons';
import { TabService } from './tab';
import { TabService } from '../tab.service';
import { ButtonComponent } from '@isa/ui/buttons';
@Component({

View File

@@ -1,9 +1,6 @@
/**
* @fileoverview Constants for tab navigation behavior and URL filtering.
*
* This module provides configuration constants that control how URLs are
* handled in the tab navigation history system.
*/
import { inject, InjectionToken } from '@angular/core';
import { Config } from '@core/config';
import { z } from 'zod';
/**
* URL patterns that should not be added to tab navigation history.
@@ -24,3 +21,11 @@
* ```
*/
export const HISTORY_BLACKLIST_PATTERNS = ['/kunde/dashboard', '/dashboard'];
export const TITLE_PREFIX = new InjectionToken('isa.core.tabs.title-prefix', {
providedIn: 'root',
factory: () => {
const config = inject(Config);
return config.get('title', z.string().default('ISA'));
},
});

View File

@@ -1,35 +1,35 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { TabService } from './tab';
import { logger } from '@isa/core/logging';
/**
* Guard that deactivates the currently active tab when entering a route.
*
* Use this guard on routes that exist outside of a process/tab context,
* such as dashboard pages or global branch operations.
*
* @example
* ```typescript
* const routes: Routes = [
* {
* path: 'dashboard',
* loadChildren: () => import('./dashboard').then(m => m.DashboardModule),
* canActivate: [deactivateTabGuard],
* },
* ];
* ```
*/
export const deactivateTabGuard: CanActivateFn = () => {
const tabService = inject(TabService);
const log = logger({ guard: 'deactivateTabGuard' });
const previousTabId = tabService.activatedTabId();
if (previousTabId !== null) {
tabService.deactivateTab();
log.debug('Tab deactivated on route activation', () => ({ previousTabId }));
}
return true;
};
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { TabService } from '../tab.service';
import { logger } from '@isa/core/logging';
/**
* Guard that deactivates the currently active tab when entering a route.
*
* Use this guard on routes that exist outside of a process/tab context,
* such as dashboard pages or global branch operations.
*
* @example
* ```typescript
* const routes: Routes = [
* {
* path: 'dashboard',
* loadChildren: () => import('./dashboard').then(m => m.DashboardModule),
* canActivate: [deactivateTabGuard],
* },
* ];
* ```
*/
export const deactivateTabGuard: CanActivateFn = () => {
const tabService = inject(TabService);
const log = logger({ guard: 'deactivateTabGuard' });
const previousTabId = tabService.activatedTabId();
if (previousTabId !== null) {
tabService.deactivateTab();
log.debug('Tab deactivated on route activation', () => ({ previousTabId }));
}
return true;
};

View File

@@ -0,0 +1,3 @@
export * from './deactivate-tab.guard';
export * from './has-tab-id.guard';
export * from './tab-cleanup.guard';

View File

@@ -1,195 +1,195 @@
import { inject } from '@angular/core';
import { CanDeactivateFn, Router } from '@angular/router';
import { TabService } from './tab';
import { logger } from '@isa/core/logging';
import {
CheckoutMetadataService,
ShoppingCartService,
} from '@isa/checkout/data-access';
import {
getNextTabNameHelper,
formatCustomerTabNameHelper,
checkCartHasItemsHelper,
} from './helpers';
import { DomainCheckoutService } from '@domain/checkout';
import { CrmTabMetadataService } from '@isa/crm/data-access';
import { firstValueFrom } from 'rxjs';
// TODO: #5484 Move Guard to other location + Use resources for fetching cart data
/**
* CanDeactivate Guard that manages tab context based on shopping cart state.
*
* This guard checks both the regular shopping cart and reward shopping cart:
* - If BOTH carts are empty (or don't exist), the tab context is cleared and renamed to "Vorgang X"
* - If EITHER cart still has items:
* - Customer context is preserved
* - Tab name is updated to show customer name (or organization name for B2B)
* - process_type is set to 'cart-checkout' to show cart icon
*
* Usage: Apply to checkout-summary routes to automatically manage tab state after order completion.
*/
export const canDeactivateTabCleanup: CanDeactivateFn<unknown> = async () => {
const tabService = inject(TabService);
const checkoutMetadataService = inject(CheckoutMetadataService);
const crmTabMetadataService = inject(CrmTabMetadataService);
const shoppingCartService = inject(ShoppingCartService);
const domainCheckoutService = inject(DomainCheckoutService);
const router = inject(Router);
const log = logger(() => ({ guard: 'TabCleanup' }));
const tabId = tabService.activatedTabId();
if (!tabId) {
log.warn('No active tab found');
return true;
}
// Check if the target URL contains a tab ID and if it matches the current tab
// Routes without tab ID (e.g., /filiale/package-inspection, /kunde/dashboard) are global areas
// Routes with different tab ID (e.g., creating new process) should not affect current tab
const nextUrl = router.getCurrentNavigation()?.finalUrl?.toString() ?? '';
const tabIdMatch = nextUrl.match(/\/(\d{10,})\//);
const targetTabId = tabIdMatch ? parseInt(tabIdMatch[1], 10) : null;
// Skip cleanup if navigating to global area or different tab
if (!targetTabId || targetTabId !== tabId) {
log.debug(
targetTabId
? 'Navigating to different tab, keeping current tab unchanged'
: 'Navigating to global area (no tab ID), keeping tab unchanged',
() => ({
currentTabId: tabId,
targetTabId,
nextUrl,
}),
);
return true;
}
try {
// Get shopping cart IDs from tab metadata
const shoppingCartId = checkoutMetadataService.getShoppingCartId(tabId);
const rewardShoppingCartId =
checkoutMetadataService.getRewardShoppingCartId(tabId);
// Load carts and check if they have items
let regularCart = null;
if (shoppingCartId) {
try {
regularCart = await shoppingCartService.getShoppingCart(shoppingCartId);
} catch (error) {
log.debug('Could not load regular shopping cart', () => ({
shoppingCartId,
error: (error as Error).message,
}));
}
}
let rewardCart = null;
if (rewardShoppingCartId) {
try {
rewardCart =
await shoppingCartService.getShoppingCart(rewardShoppingCartId);
} catch (error) {
log.debug('Could not load reward shopping cart', () => ({
rewardShoppingCartId,
error: (error as Error).message,
}));
}
}
const hasRegularItems = checkCartHasItemsHelper(regularCart);
const hasRewardItems = checkCartHasItemsHelper(rewardCart);
log.debug('Cart status check', () => ({
tabId,
shoppingCartId,
rewardShoppingCartId,
hasRegularItems,
hasRewardItems,
}));
// If either cart has items, preserve context and update tab name with customer info
if (hasRegularItems || hasRewardItems) {
log.info(
'Preserving checkout context - cart(s) still have items',
() => ({
tabId,
hasRegularItems,
hasRewardItems,
}),
);
try {
// Get customer from checkout service
const customer = await firstValueFrom(
domainCheckoutService.getCustomer({ processId: tabId }),
);
if (customer) {
const name = formatCustomerTabNameHelper(customer);
if (name) {
// Update tab name with customer info
tabService.patchTab(tabId, { name });
// Ensure process_type is 'cart' for proper cart icon display
tabService.patchTabMetadata(tabId, {
process_type: 'cart',
});
log.info('Updated tab name with customer info', () => ({
tabId,
customerName: name,
}));
}
}
} catch (error) {
// If customer data can't be loaded, just log and continue
log.warn('Could not load customer for tab name update', () => ({
tabId,
error: (error as Error).message,
}));
}
return true;
}
// Both carts are empty - clean up context
log.info('Cleaning up checkout context - both carts empty', () => ({
tabId,
}));
// Remove checkout state from store (customer, buyer, payer, etc.)
domainCheckoutService.removeProcess({ processId: tabId });
// Clear customer-related metadata (prevents old customer data from being reused)
crmTabMetadataService.setSelectedCustomerId(tabId, undefined);
crmTabMetadataService.setSelectedPayerId(tabId, undefined);
crmTabMetadataService.setSelectedShippingAddressId(tabId, undefined);
// Create new shopping cart and update Store (this automatically dispatches setShoppingCart action)
await firstValueFrom(
domainCheckoutService.createShoppingCart({ processId: tabId }),
);
// Clear tab metadata and location history, but keep process_type for cart icon
tabService.patchTabMetadata(tabId, { process_type: 'cart' });
tabService.clearLocationHistory(tabId);
// Rename tab to next "Vorgang X" based on count of existing Vorgang tabs
const tabName = getNextTabNameHelper(tabService.entityMap());
tabService.patchTab(tabId, { name: tabName });
log.info('Tab reset to clean state', () => ({
tabId,
name: tabName,
}));
return true;
} catch (error) {
log.error('Error in checkout cleanup guard', error as Error, () => ({
tabId,
}));
return true; // Allow navigation even if cleanup fails
}
};
import { inject } from '@angular/core';
import { CanDeactivateFn, Router } from '@angular/router';
import { TabService } from '../tab.service';
import { logger } from '@isa/core/logging';
import {
CheckoutMetadataService,
ShoppingCartService,
} from '@isa/checkout/data-access';
import {
getNextTabNameHelper,
formatCustomerTabNameHelper,
checkCartHasItemsHelper,
} from '../helpers';
import { DomainCheckoutService } from '@domain/checkout';
import { CrmTabMetadataService } from '@isa/crm/data-access';
import { firstValueFrom } from 'rxjs';
// TODO: #5484 Move Guard to other location + Use resources for fetching cart data
/**
* CanDeactivate Guard that manages tab context based on shopping cart state.
*
* This guard checks both the regular shopping cart and reward shopping cart:
* - If BOTH carts are empty (or don't exist), the tab context is cleared and renamed to "Vorgang X"
* - If EITHER cart still has items:
* - Customer context is preserved
* - Tab name is updated to show customer name (or organization name for B2B)
* - process_type is set to 'cart-checkout' to show cart icon
*
* Usage: Apply to checkout-summary routes to automatically manage tab state after order completion.
*/
export const canDeactivateTabCleanup: CanDeactivateFn<unknown> = async () => {
const tabService = inject(TabService);
const checkoutMetadataService = inject(CheckoutMetadataService);
const crmTabMetadataService = inject(CrmTabMetadataService);
const shoppingCartService = inject(ShoppingCartService);
const domainCheckoutService = inject(DomainCheckoutService);
const router = inject(Router);
const log = logger(() => ({ guard: 'TabCleanup' }));
const tabId = tabService.activatedTabId();
if (!tabId) {
log.warn('No active tab found');
return true;
}
// Check if the target URL contains a tab ID and if it matches the current tab
// Routes without tab ID (e.g., /filiale/package-inspection, /kunde/dashboard) are global areas
// Routes with different tab ID (e.g., creating new process) should not affect current tab
const nextUrl = router.getCurrentNavigation()?.finalUrl?.toString() ?? '';
const tabIdMatch = nextUrl.match(/\/(\d{10,})\//);
const targetTabId = tabIdMatch ? parseInt(tabIdMatch[1], 10) : null;
// Skip cleanup if navigating to global area or different tab
if (!targetTabId || targetTabId !== tabId) {
log.debug(
targetTabId
? 'Navigating to different tab, keeping current tab unchanged'
: 'Navigating to global area (no tab ID), keeping tab unchanged',
() => ({
currentTabId: tabId,
targetTabId,
nextUrl,
}),
);
return true;
}
try {
// Get shopping cart IDs from tab metadata
const shoppingCartId = checkoutMetadataService.getShoppingCartId(tabId);
const rewardShoppingCartId =
checkoutMetadataService.getRewardShoppingCartId(tabId);
// Load carts and check if they have items
let regularCart = null;
if (shoppingCartId) {
try {
regularCart = await shoppingCartService.getShoppingCart(shoppingCartId);
} catch (error) {
log.debug('Could not load regular shopping cart', () => ({
shoppingCartId,
error: (error as Error).message,
}));
}
}
let rewardCart = null;
if (rewardShoppingCartId) {
try {
rewardCart =
await shoppingCartService.getShoppingCart(rewardShoppingCartId);
} catch (error) {
log.debug('Could not load reward shopping cart', () => ({
rewardShoppingCartId,
error: (error as Error).message,
}));
}
}
const hasRegularItems = checkCartHasItemsHelper(regularCart);
const hasRewardItems = checkCartHasItemsHelper(rewardCart);
log.debug('Cart status check', () => ({
tabId,
shoppingCartId,
rewardShoppingCartId,
hasRegularItems,
hasRewardItems,
}));
// If either cart has items, preserve context and update tab name with customer info
if (hasRegularItems || hasRewardItems) {
log.info(
'Preserving checkout context - cart(s) still have items',
() => ({
tabId,
hasRegularItems,
hasRewardItems,
}),
);
try {
// Get customer from checkout service
const customer = await firstValueFrom(
domainCheckoutService.getCustomer({ processId: tabId }),
);
if (customer) {
const name = formatCustomerTabNameHelper(customer);
if (name) {
// Update tab name with customer info
tabService.patchTab(tabId, { name });
// Ensure process_type is 'cart' for proper cart icon display
tabService.patchTabMetadata(tabId, {
process_type: 'cart',
});
log.info('Updated tab name with customer info', () => ({
tabId,
customerName: name,
}));
}
}
} catch (error) {
// If customer data can't be loaded, just log and continue
log.warn('Could not load customer for tab name update', () => ({
tabId,
error: (error as Error).message,
}));
}
return true;
}
// Both carts are empty - clean up context
log.info('Cleaning up checkout context - both carts empty', () => ({
tabId,
}));
// Remove checkout state from store (customer, buyer, payer, etc.)
domainCheckoutService.removeProcess({ processId: tabId });
// Clear customer-related metadata (prevents old customer data from being reused)
crmTabMetadataService.setSelectedCustomerId(tabId, undefined);
crmTabMetadataService.setSelectedPayerId(tabId, undefined);
crmTabMetadataService.setSelectedShippingAddressId(tabId, undefined);
// Create new shopping cart and update Store (this automatically dispatches setShoppingCart action)
await firstValueFrom(
domainCheckoutService.createShoppingCart({ processId: tabId }),
);
// Clear tab metadata and location history, but keep process_type for cart icon
tabService.patchTabMetadata(tabId, { process_type: 'cart' });
tabService.clearLocationHistory(tabId);
// Rename tab to next "Vorgang X" based on count of existing Vorgang tabs
const tabName = getNextTabNameHelper(tabService.entityMap());
tabService.patchTab(tabId, { name: tabName });
log.info('Tab reset to clean state', () => ({
tabId,
name: tabName,
}));
return true;
} catch (error) {
log.error('Error in checkout cleanup guard', error as Error, () => ({
tabId,
}));
return true; // Allow navigation even if cleanup fails
}
};

View File

@@ -1,341 +1,346 @@
/**
* @fileoverview Tab history pruning utilities with multiple strategies for managing navigation history size.
*
* This module provides sophisticated history management for tab navigation:
* - Three pruning strategies: oldest, balanced, and smart
* - Forward history limiting when adding new locations
* - Index validation and correction utilities
* - Configurable pruning behavior per tab or globally
*
* The pruning system prevents unlimited memory growth while preserving
* navigation functionality and user experience. Each strategy offers
* different trade-offs between memory usage and history preservation.
*/
import { TabLocation, TabLocationHistory } from './schemas';
import { TabConfig } from './tab-config';
/**
* Result of a history pruning operation.
*
* Contains the pruned location array, updated current index,
* and metadata about the pruning operation performed.
*/
export interface HistoryPruningResult {
/** Array of locations after pruning */
locations: TabLocation[];
/** Updated current index after pruning */
newCurrent: number;
/** Number of entries removed during pruning */
entriesRemoved: number;
/** Name of the pruning strategy used */
strategy: string;
}
/**
* Static utility class for managing tab navigation history pruning.
*
* Provides multiple strategies for reducing history size while maintaining
* navigation functionality. All methods are static and stateless.
*/
export class TabHistoryPruner {
/**
* Prunes history based on the configured strategy.
*
* Automatically selects and applies the appropriate pruning strategy
* based on configuration. Supports per-tab metadata overrides.
*
* @param locationHistory - Current tab location history to prune
* @param config - Global tab configuration
* @param tabMetadata - Optional per-tab configuration overrides
* @returns Pruning result with updated locations and metadata
*
* @example
* ```typescript
* const result = TabHistoryPruner.pruneHistory(
* tab.location,
* globalConfig,
* { maxHistorySize: 25 }
* );
* if (result.entriesRemoved > 0) {
* console.log(`Pruned ${result.entriesRemoved} entries using ${result.strategy}`);
* }
* ```
*/
static pruneHistory(
locationHistory: TabLocationHistory,
config: TabConfig,
tabMetadata?: { maxHistorySize?: number; maxForwardHistory?: number }
): HistoryPruningResult {
const maxSize = tabMetadata?.maxHistorySize ?? config.maxHistorySize;
const { locations, current } = locationHistory;
if (locations.length <= maxSize) {
return {
locations: [...locations],
newCurrent: current,
entriesRemoved: 0,
strategy: 'no-pruning'
};
}
const strategy = config.pruningStrategy;
switch (strategy) {
case 'oldest':
return this.pruneOldestFirst(locations, current, maxSize);
case 'balanced':
return this.pruneBalanced(locations, current, maxSize);
case 'smart':
return this.pruneSmart(locations, current, maxSize, config);
default:
return this.pruneBalanced(locations, current, maxSize);
}
}
/**
* Removes oldest entries first, adjusting current index.
*
* Simple FIFO (First In, First Out) pruning strategy that removes
* the oldest history entries when the size limit is exceeded.
* Preserves recent navigation while maintaining current position.
*
* @param locations - Array of tab locations to prune
* @param current - Current index in the locations array
* @param maxSize - Maximum number of locations to keep
* @returns Pruning result with updated locations and index
*
* @example
* ```typescript
* // With locations [A, B, C, D, E] and current=2 (C), maxSize=3
* // Result: [C, D, E] with newCurrent=0 (still pointing to C)
* ```
*/
private static pruneOldestFirst(
locations: TabLocation[],
current: number,
maxSize: number
): HistoryPruningResult {
const removeCount = locations.length - maxSize;
const prunedLocations = locations.slice(removeCount);
const newCurrent = Math.max(-1, current - removeCount);
return {
locations: prunedLocations,
newCurrent,
entriesRemoved: removeCount,
strategy: 'oldest-first'
};
}
/**
* Keeps entries balanced around the current position.
*
* Maintains a balanced window around the current location, preserving
* 70% of entries before current and 30% after. This strategy provides
* good back/forward navigation while respecting size limits.
*
* @param locations - Array of tab locations to prune
* @param current - Current index in the locations array
* @param maxSize - Maximum number of locations to keep
* @returns Pruning result with maintained current position
*
* @example
* ```typescript
* // With current=5 in 20 locations, maxSize=10
* // Keeps ~7 entries before current, current entry, ~2 entries after
* // Result preserves navigation context around current position
* ```
*/
private static pruneBalanced(
locations: TabLocation[],
current: number,
maxSize: number
): HistoryPruningResult {
// Preserve 70% of entries before current, 30% after
const backwardRatio = 0.7;
const maxBackward = Math.floor(maxSize * backwardRatio);
const maxForward = maxSize - maxBackward - 1; // -1 for current item
const keepStart = Math.max(0, current - maxBackward);
const keepEnd = Math.min(locations.length, current + 1 + maxForward);
const prunedLocations = locations.slice(keepStart, keepEnd);
const newCurrent = current - keepStart;
const entriesRemoved = locations.length - prunedLocations.length;
return {
locations: prunedLocations,
newCurrent,
entriesRemoved,
strategy: 'balanced'
};
}
/**
* Intelligent pruning based on usage patterns and recency.
*
* Uses a scoring algorithm that considers both recency (how recently
* a location was visited) and proximity (how close to current position).
* Recent locations and those near the current position get higher scores
* and are more likely to be preserved.
*
* Scoring factors:
* - Recent (< 1 hour): 100 points base
* - Medium (< 1 day): 60 points base
* - Old (> 1 day): 20 points base
* - Proximity: 100 - (distance_from_current * 10) points
*
* @param locations - Array of tab locations to prune
* @param current - Current index in the locations array
* @param maxSize - Maximum number of locations to keep
* @param config - Tab configuration (unused but kept for consistency)
* @returns Pruning result with intelligently selected locations
*
* @example
* ```typescript
* // Preserves recently visited pages and those near current position
* // while removing old, distant entries first
* ```
*/
private static pruneSmart(
locations: TabLocation[],
current: number,
maxSize: number,
config: TabConfig
): HistoryPruningResult {
const now = Date.now();
const oneHour = 60 * 60 * 1000;
const oneDay = 24 * oneHour;
// Score each location based on recency and distance from current
const scoredLocations = locations.map((location, index) => {
const age = now - location.timestamp;
const distanceFromCurrent = Math.abs(index - current);
// Recent locations get higher scores
let recencyScore = 100;
if (age > oneDay) recencyScore = 20;
else if (age > oneHour) recencyScore = 60;
// Locations near current position get higher scores
const proximityScore = Math.max(0, 100 - (distanceFromCurrent * 10));
return {
location,
index,
score: recencyScore + proximityScore,
isCurrent: index === current
};
});
// Always keep current location and sort others by score
const currentItem = scoredLocations.find(item => item.isCurrent);
const otherItems = scoredLocations
.filter(item => !item.isCurrent)
.sort((a, b) => b.score - a.score);
// Take top scoring items
const itemsToKeep = Math.min(maxSize - 1, otherItems.length); // -1 for current
const keptItems = otherItems.slice(0, itemsToKeep);
if (currentItem) {
keptItems.push(currentItem);
}
// Sort by original index to maintain order
keptItems.sort((a, b) => a.index - b.index);
const prunedLocations = keptItems.map(item => item.location);
const newCurrent = keptItems.findIndex(item => item.isCurrent);
const entriesRemoved = locations.length - prunedLocations.length;
return {
locations: prunedLocations,
newCurrent: newCurrent === -1 ? Math.max(0, prunedLocations.length - 1) : newCurrent,
entriesRemoved,
strategy: 'smart'
};
}
/**
* Prunes forward history when adding a new location.
*
* Limits the number of forward history entries that are preserved
* when navigating to a new location. This prevents unlimited
* forward history accumulation while maintaining reasonable redo depth.
*
* @param locations - Current array of tab locations
* @param current - Current index in the locations array
* @param maxForwardHistory - Maximum forward entries to preserve
* @returns Object with pruned locations and unchanged current index
*
* @example
* ```typescript
* // With current=2, maxForwardHistory=2
* // [A, B, C, D, E, F] becomes [A, B, C, D, E]
* // Preserves current position while limiting forward entries
* ```
*/
static pruneForwardHistory(
locations: TabLocation[],
current: number,
maxForwardHistory: number
): { locations: TabLocation[]; newCurrent: number } {
if (current < 0 || current >= locations.length) {
return { locations: [...locations], newCurrent: current };
}
const beforeCurrent = locations.slice(0, current + 1);
const afterCurrent = locations.slice(current + 1);
const limitedAfter = afterCurrent.slice(0, maxForwardHistory);
const prunedLocations = [...beforeCurrent, ...limitedAfter];
return {
locations: prunedLocations,
newCurrent: current // Current position unchanged
};
}
/**
* Validates and corrects location history index.
*
* Ensures the current index is within valid bounds for the locations array.
* Corrects invalid indices to the nearest valid value and reports whether
* correction was needed. Essential for maintaining data integrity after
* history modifications.
*
* @param locations - Array of tab locations to validate against
* @param current - Current index to validate
* @returns Object with corrected index and validation status
*
* @example
* ```typescript
* const { index, wasInvalid } = TabHistoryPruner.validateLocationIndex(
* locations,
* currentIndex
* );
* if (wasInvalid) {
* console.warn(`Invalid index ${currentIndex} corrected to ${index}`);
* }
* ```
*/
static validateLocationIndex(
locations: TabLocation[],
current: number
): { index: number; wasInvalid: boolean } {
if (locations.length === 0) {
return { index: -1, wasInvalid: current !== -1 };
}
if (current < -1 || current >= locations.length) {
// Invalid index, correct to last valid position
const correctedIndex = Math.max(-1, Math.min(locations.length - 1, current));
return { index: correctedIndex, wasInvalid: true };
}
return { index: current, wasInvalid: false };
}
}
/**
* @fileoverview Tab history pruning utilities with multiple strategies for managing navigation history size.
*
* This module provides sophisticated history management for tab navigation:
* - Three pruning strategies: oldest, balanced, and smart
* - Forward history limiting when adding new locations
* - Index validation and correction utilities
* - Configurable pruning behavior per tab or globally
*
* The pruning system prevents unlimited memory growth while preserving
* navigation functionality and user experience. Each strategy offers
* different trade-offs between memory usage and history preservation.
*/
import { TabLocation, TabLocationHistory } from '../schemas';
import { TabConfig } from '../tab-config';
/**
* Result of a history pruning operation.
*
* Contains the pruned location array, updated current index,
* and metadata about the pruning operation performed.
*/
export interface HistoryPruningResult {
/** Array of locations after pruning */
locations: TabLocation[];
/** Updated current index after pruning */
newCurrent: number;
/** Number of entries removed during pruning */
entriesRemoved: number;
/** Name of the pruning strategy used */
strategy: string;
}
/**
* Static utility class for managing tab navigation history pruning.
*
* Provides multiple strategies for reducing history size while maintaining
* navigation functionality. All methods are static and stateless.
*/
export class TabHistoryPruner {
/**
* Prunes history based on the configured strategy.
*
* Automatically selects and applies the appropriate pruning strategy
* based on configuration. Supports per-tab metadata overrides.
*
* @param locationHistory - Current tab location history to prune
* @param config - Global tab configuration
* @param tabMetadata - Optional per-tab configuration overrides
* @returns Pruning result with updated locations and metadata
*
* @example
* ```typescript
* const result = TabHistoryPruner.pruneHistory(
* tab.location,
* globalConfig,
* { maxHistorySize: 25 }
* );
* if (result.entriesRemoved > 0) {
* console.log(`Pruned ${result.entriesRemoved} entries using ${result.strategy}`);
* }
* ```
*/
static pruneHistory(
locationHistory: TabLocationHistory,
config: TabConfig,
tabMetadata?: { maxHistorySize?: number; maxForwardHistory?: number },
): HistoryPruningResult {
const maxSize = tabMetadata?.maxHistorySize ?? config.maxHistorySize;
const { locations, current } = locationHistory;
if (locations.length <= maxSize) {
return {
locations: [...locations],
newCurrent: current,
entriesRemoved: 0,
strategy: 'no-pruning',
};
}
const strategy = config.pruningStrategy;
switch (strategy) {
case 'oldest':
return this.pruneOldestFirst(locations, current, maxSize);
case 'balanced':
return this.pruneBalanced(locations, current, maxSize);
case 'smart':
return this.pruneSmart(locations, current, maxSize, config);
default:
return this.pruneBalanced(locations, current, maxSize);
}
}
/**
* Removes oldest entries first, adjusting current index.
*
* Simple FIFO (First In, First Out) pruning strategy that removes
* the oldest history entries when the size limit is exceeded.
* Preserves recent navigation while maintaining current position.
*
* @param locations - Array of tab locations to prune
* @param current - Current index in the locations array
* @param maxSize - Maximum number of locations to keep
* @returns Pruning result with updated locations and index
*
* @example
* ```typescript
* // With locations [A, B, C, D, E] and current=2 (C), maxSize=3
* // Result: [C, D, E] with newCurrent=0 (still pointing to C)
* ```
*/
private static pruneOldestFirst(
locations: TabLocation[],
current: number,
maxSize: number,
): HistoryPruningResult {
const removeCount = locations.length - maxSize;
const prunedLocations = locations.slice(removeCount);
const newCurrent = Math.max(-1, current - removeCount);
return {
locations: prunedLocations,
newCurrent,
entriesRemoved: removeCount,
strategy: 'oldest-first',
};
}
/**
* Keeps entries balanced around the current position.
*
* Maintains a balanced window around the current location, preserving
* 70% of entries before current and 30% after. This strategy provides
* good back/forward navigation while respecting size limits.
*
* @param locations - Array of tab locations to prune
* @param current - Current index in the locations array
* @param maxSize - Maximum number of locations to keep
* @returns Pruning result with maintained current position
*
* @example
* ```typescript
* // With current=5 in 20 locations, maxSize=10
* // Keeps ~7 entries before current, current entry, ~2 entries after
* // Result preserves navigation context around current position
* ```
*/
private static pruneBalanced(
locations: TabLocation[],
current: number,
maxSize: number,
): HistoryPruningResult {
// Preserve 70% of entries before current, 30% after
const backwardRatio = 0.7;
const maxBackward = Math.floor(maxSize * backwardRatio);
const maxForward = maxSize - maxBackward - 1; // -1 for current item
const keepStart = Math.max(0, current - maxBackward);
const keepEnd = Math.min(locations.length, current + 1 + maxForward);
const prunedLocations = locations.slice(keepStart, keepEnd);
const newCurrent = current - keepStart;
const entriesRemoved = locations.length - prunedLocations.length;
return {
locations: prunedLocations,
newCurrent,
entriesRemoved,
strategy: 'balanced',
};
}
/**
* Intelligent pruning based on usage patterns and recency.
*
* Uses a scoring algorithm that considers both recency (how recently
* a location was visited) and proximity (how close to current position).
* Recent locations and those near the current position get higher scores
* and are more likely to be preserved.
*
* Scoring factors:
* - Recent (< 1 hour): 100 points base
* - Medium (< 1 day): 60 points base
* - Old (> 1 day): 20 points base
* - Proximity: 100 - (distance_from_current * 10) points
*
* @param locations - Array of tab locations to prune
* @param current - Current index in the locations array
* @param maxSize - Maximum number of locations to keep
* @param config - Tab configuration (unused but kept for consistency)
* @returns Pruning result with intelligently selected locations
*
* @example
* ```typescript
* // Preserves recently visited pages and those near current position
* // while removing old, distant entries first
* ```
*/
private static pruneSmart(
locations: TabLocation[],
current: number,
maxSize: number,
config: TabConfig,
): HistoryPruningResult {
const now = Date.now();
const oneHour = 60 * 60 * 1000;
const oneDay = 24 * oneHour;
// Score each location based on recency and distance from current
const scoredLocations = locations.map((location, index) => {
const age = now - location.timestamp;
const distanceFromCurrent = Math.abs(index - current);
// Recent locations get higher scores
let recencyScore = 100;
if (age > oneDay) recencyScore = 20;
else if (age > oneHour) recencyScore = 60;
// Locations near current position get higher scores
const proximityScore = Math.max(0, 100 - distanceFromCurrent * 10);
return {
location,
index,
score: recencyScore + proximityScore,
isCurrent: index === current,
};
});
// Always keep current location and sort others by score
const currentItem = scoredLocations.find((item) => item.isCurrent);
const otherItems = scoredLocations
.filter((item) => !item.isCurrent)
.sort((a, b) => b.score - a.score);
// Take top scoring items
const itemsToKeep = Math.min(maxSize - 1, otherItems.length); // -1 for current
const keptItems = otherItems.slice(0, itemsToKeep);
if (currentItem) {
keptItems.push(currentItem);
}
// Sort by original index to maintain order
keptItems.sort((a, b) => a.index - b.index);
const prunedLocations = keptItems.map((item) => item.location);
const newCurrent = keptItems.findIndex((item) => item.isCurrent);
const entriesRemoved = locations.length - prunedLocations.length;
return {
locations: prunedLocations,
newCurrent:
newCurrent === -1
? Math.max(0, prunedLocations.length - 1)
: newCurrent,
entriesRemoved,
strategy: 'smart',
};
}
/**
* Prunes forward history when adding a new location.
*
* Limits the number of forward history entries that are preserved
* when navigating to a new location. This prevents unlimited
* forward history accumulation while maintaining reasonable redo depth.
*
* @param locations - Current array of tab locations
* @param current - Current index in the locations array
* @param maxForwardHistory - Maximum forward entries to preserve
* @returns Object with pruned locations and unchanged current index
*
* @example
* ```typescript
* // With current=2, maxForwardHistory=2
* // [A, B, C, D, E, F] becomes [A, B, C, D, E]
* // Preserves current position while limiting forward entries
* ```
*/
static pruneForwardHistory(
locations: TabLocation[],
current: number,
maxForwardHistory: number,
): { locations: TabLocation[]; newCurrent: number } {
if (current < 0 || current >= locations.length) {
return { locations: [...locations], newCurrent: current };
}
const beforeCurrent = locations.slice(0, current + 1);
const afterCurrent = locations.slice(current + 1);
const limitedAfter = afterCurrent.slice(0, maxForwardHistory);
const prunedLocations = [...beforeCurrent, ...limitedAfter];
return {
locations: prunedLocations,
newCurrent: current, // Current position unchanged
};
}
/**
* Validates and corrects location history index.
*
* Ensures the current index is within valid bounds for the locations array.
* Corrects invalid indices to the nearest valid value and reports whether
* correction was needed. Essential for maintaining data integrity after
* history modifications.
*
* @param locations - Array of tab locations to validate against
* @param current - Current index to validate
* @returns Object with corrected index and validation status
*
* @example
* ```typescript
* const { index, wasInvalid } = TabHistoryPruner.validateLocationIndex(
* locations,
* currentIndex
* );
* if (wasInvalid) {
* console.warn(`Invalid index ${currentIndex} corrected to ${index}`);
* }
* ```
*/
static validateLocationIndex(
locations: TabLocation[],
current: number,
): { index: number; wasInvalid: boolean } {
if (locations.length === 0) {
return { index: -1, wasInvalid: current !== -1 };
}
if (current < -1 || current >= locations.length) {
// Invalid index, correct to last valid position
const correctedIndex = Math.max(
-1,
Math.min(locations.length - 1, current),
);
return { index: correctedIndex, wasInvalid: true };
}
return { index: current, wasInvalid: false };
}
}

View File

@@ -1,10 +1,10 @@
import { Injectable, inject } from '@angular/core';
import { Injectable, inject, Injector } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';
import { TabService } from './tab';
import { TabLocation } from './schemas';
import { TabService } from '../tab.service';
import { TabLocation } from '../schemas';
import { Title } from '@angular/platform-browser';
import { HISTORY_BLACKLIST_PATTERNS } from './tab-navigation.constants';
import { HISTORY_BLACKLIST_PATTERNS } from '../constants';
/**
* Service that automatically syncs browser navigation events to tab location history.
@@ -24,12 +24,19 @@ import { HISTORY_BLACKLIST_PATTERNS } from './tab-navigation.constants';
* The service is designed to work seamlessly with the tab history pruning system,
* providing fallback mechanisms when navigation history has been pruned.
*/
@Injectable({ providedIn: 'root' })
@Injectable()
export class TabNavigationService {
#router = inject(Router);
#tabService = inject(TabService);
#injector = inject(Injector);
#title = inject(Title);
// Lazy injection to avoid circular dependency:
// TabNavigationService → TabService → UserStorageProvider → USER_SUB (requires auth)
// The environment initializer runs before app initializer completes authentication
#getTabService() {
return this.#injector.get(TabService);
}
init() {
this.#router.events
.pipe(filter((event) => event instanceof NavigationEnd))
@@ -51,14 +58,14 @@ export class TabNavigationService {
const location = this.#createTabLocation(event.url);
// Check if this location already exists in history (browser back/forward)
const currentTab = this.#tabService.entityMap()[activeTabId];
const currentTab = this.#getTabService().entityMap()[activeTabId];
if (
currentTab &&
this.#isLocationInHistory(currentTab.location.locations, location)
) {
this.#handleBrowserNavigation(activeTabId, location);
} else {
this.#tabService.navigateToLocation(activeTabId, location);
this.#getTabService().navigateToLocation(activeTabId, location);
}
}
@@ -72,7 +79,9 @@ export class TabNavigationService {
* @returns true if the URL should be skipped, false otherwise
*/
#shouldSkipHistory(url: string): boolean {
return HISTORY_BLACKLIST_PATTERNS.some(pattern => url.startsWith(pattern));
return HISTORY_BLACKLIST_PATTERNS.some((pattern) =>
url.startsWith(pattern),
);
}
#getActiveTabId(url: string): number | null {
@@ -91,7 +100,7 @@ export class TabNavigationService {
}
// If no ID in URL, use currently activated tab
return this.#tabService.activatedTabId();
return this.#getTabService().activatedTabId();
}
#createTabLocation(url: string): TabLocation {
@@ -131,7 +140,7 @@ export class TabNavigationService {
* @private
*/
#handleBrowserNavigation(tabId: number, location: TabLocation) {
const currentTab = this.#tabService.entityMap()[tabId];
const currentTab = this.#getTabService().entityMap()[tabId];
if (!currentTab) return;
const locationIndex = currentTab.location.locations.findIndex(
@@ -140,7 +149,7 @@ export class TabNavigationService {
// If location not found in history (possibly pruned), navigate to new location
if (locationIndex === -1) {
this.#tabService.navigateToLocation(tabId, location);
this.#getTabService().navigateToLocation(tabId, location);
return;
}
@@ -154,7 +163,7 @@ export class TabNavigationService {
const maxAttempts = Math.abs(steps) + 5; // Prevent infinite loops
while (steps > 0 && attempts < maxAttempts) {
const result = this.#tabService.navigateBack(tabId);
const result = this.#getTabService().navigateBack(tabId);
if (!result) break;
steps--;
attempts++;
@@ -162,7 +171,7 @@ export class TabNavigationService {
// If we couldn't reach the target, fallback to direct navigation
if (steps > 0) {
this.#tabService.navigateToLocation(tabId, location);
this.#getTabService().navigateToLocation(tabId, location);
}
} else if (locationIndex > currentIndex) {
// Navigate forward
@@ -171,7 +180,7 @@ export class TabNavigationService {
const maxAttempts = Math.abs(steps) + 5; // Prevent infinite loops
while (steps > 0 && attempts < maxAttempts) {
const result = this.#tabService.navigateForward(tabId);
const result = this.#getTabService().navigateForward(tabId);
if (!result) break;
steps--;
attempts++;
@@ -179,7 +188,7 @@ export class TabNavigationService {
// If we couldn't reach the target, fallback to direct navigation
if (steps > 0) {
this.#tabService.navigateToLocation(tabId, location);
this.#getTabService().navigateToLocation(tabId, location);
}
}
// If locationIndex === currentIndex, we're already at the right position
@@ -210,7 +219,7 @@ export class TabNavigationService {
if (activeTabId) {
const location = this.#createTabLocation(url);
this.#tabService.navigateToLocation(activeTabId, location);
this.#getTabService().navigateToLocation(activeTabId, location);
}
}
}

View File

@@ -2,7 +2,7 @@ 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';
import { TITLE_PREFIX } from '../constants';
/**
* Custom TitleStrategy for the ISA application that:
@@ -41,8 +41,8 @@ import { TITLE_PREFIX } from './title-prefix';
* ];
* ```
*/
@Injectable({ providedIn: 'root' })
export class IsaTitleStrategy extends TitleStrategy {
@Injectable()
export class TabTitleStrategy extends TitleStrategy {
readonly #title = inject(Title);
readonly #injector = inject(Injector);
readonly #titlePrefix = inject(TITLE_PREFIX);

View File

@@ -0,0 +1,19 @@
import {
EnvironmentProviders,
inject,
makeEnvironmentProviders,
provideEnvironmentInitializer,
} from '@angular/core';
import { TabTitleStrategy } from './internal/tab-title.strategy';
import { TitleStrategy } from '@angular/router';
import { TabNavigationService } from './internal/tab-navigation.service';
export function provideCoreTabs(): EnvironmentProviders {
return makeEnvironmentProviders([
TabNavigationService,
{ provide: TitleStrategy, useClass: TabTitleStrategy },
provideEnvironmentInitializer(() => {
inject(TabNavigationService).init();
}),
]);
}

View File

@@ -0,0 +1 @@
export * from './tab.resolver-fn';

View File

@@ -1,9 +1,9 @@
import { ResolveFn } from '@angular/router';
import { TabService } from './tab';
import { Tab } from './schemas';
import { TabService } from '../tab.service';
import { Tab } from '../schemas';
import { inject } from '@angular/core';
import { logger } from '@isa/core/logging';
import { getNextTabNameHelper } from './helpers';
import { getNextTabNameHelper } from '../helpers';
export const tabResolverFn: ResolveFn<Tab> = (route) => {
const log = logger(() => ({

View File

@@ -89,6 +89,24 @@ export const TabTagsSchema = z.array(z.string()).default([]);
/** TypeScript type for tab tags */
export type TabTags = z.infer<typeof TabTagsSchema>;
/**
* Schema for tab icon route configuration.
* Mirrors NavigationRoute from shell-navigation for icon click navigation.
*/
export const TabIconRouteSchema = z.object({
/** The route path - can be a string or array of segments. */
route: z.union([z.string(), z.array(z.unknown())]),
/** Query parameters to append to the route. */
queryParams: z
.record(z.union([z.string(), z.number(), z.boolean()]))
.optional(),
/** Strategy for handling existing query parameters. */
queryParamsHandling: z.enum(['merge', 'preserve', '']).optional(),
});
/** TypeScript type for tab icon route */
export type TabIconRoute = z.infer<typeof TabIconRouteSchema>;
/**
* Base schema for tab validation (runtime validation only).
*
@@ -112,6 +130,12 @@ export const TabSchema = z.object({
location: TabLocationHistorySchema,
/** Array of tags for organization */
tags: TabTagsSchema,
/** SVG string for tab icon */
icon: z.string().nullish(),
/** Indicator badge (boolean for dot, number for count) */
indicator: z.union([z.boolean(), z.number()]).nullish(),
/** Route to navigate when icon is clicked */
iconRoute: TabIconRouteSchema.nullish(),
});
/**
@@ -137,6 +161,12 @@ export interface Tab {
location: TabLocationHistory;
/** Organization tags */
tags: string[];
/** SVG string for tab icon */
icon?: string | null;
/** Indicator badge (boolean for dot, number for count) */
indicator?: boolean | number | null;
/** Route to navigate when icon is clicked */
iconRoute?: TabIconRoute | null;
}
/**
@@ -162,6 +192,12 @@ export interface TabCreate {
location: TabLocationHistory;
/** Organization tags */
tags: string[];
/** SVG string for tab icon */
icon?: string | null;
/** Indicator badge (boolean for dot, number for count) */
indicator?: boolean | number | null;
/** Route to navigate when icon is clicked */
iconRoute?: TabIconRoute | null;
}
/**
@@ -188,6 +224,12 @@ export const PersistedTabSchema = z
location: TabLocationHistorySchema,
/** Organization tags */
tags: TabTagsSchema,
/** SVG string for tab icon */
icon: z.string().nullish(),
/** Indicator badge (boolean for dot, number for count) */
indicator: z.union([z.boolean(), z.number()]).nullish(),
/** Route to navigate when icon is clicked */
iconRoute: TabIconRouteSchema.nullish(),
})
.strict();
@@ -213,6 +255,12 @@ export const AddTabSchema = z.object({
id: z.number().optional(),
/** Optional activation timestamp */
activatedAt: z.number().optional(),
/** SVG string for tab icon */
icon: z.string().nullish(),
/** Indicator badge (boolean for dot, number for count) */
indicator: z.union([z.boolean(), z.number()]).nullish(),
/** Route to navigate when icon is clicked */
iconRoute: TabIconRouteSchema.nullish(),
});
/** TypeScript type for adding tabs */
@@ -241,6 +289,12 @@ export const TabUpdateSchema = z
location: TabLocationHistorySchema.optional(),
/** Updated tags array */
tags: z.array(z.string()).optional(),
/** Updated SVG string for tab icon */
icon: z.string().nullish(),
/** Updated indicator badge (boolean for dot, number for count) */
indicator: z.union([z.boolean(), z.number()]).nullish(),
/** Updated route to navigate when icon is clicked */
iconRoute: TabIconRouteSchema.nullish(),
})
.strict();

View File

@@ -1,8 +1,9 @@
import { computed, inject, Signal } from '@angular/core';
import { computed, effect, inject, Signal, untracked } from '@angular/core';
import { Params, QueryParamsHandling } from '@angular/router';
import { Config } from '@isa/core/config';
import { logger } from '@isa/core/logging';
import { z } from 'zod';
import { TabService } from './tab';
import { TabService } from './tab.service';
const ReservedProcessIdsSchema = z.object({
goodsOut: z.number(),
@@ -251,3 +252,64 @@ export function injectLabeledLegacyTabRoute(
};
});
}
/**
* Sets up reactive tab subtitle management.
* Automatically updates the active tab's subtitle when the source signal changes.
*
* @param source - Signal containing the data to derive subtitle from
* @param transform - Function to extract subtitle string from source data
* @param fallback - Optional fallback string when transform returns undefined/null
*
* @example
* // In component constructor:
* useTabSubtitle(this.customerSignal, customer => getCustomerName(customer), 'Kundendetails');
*/
export function useTabSubtitle<T>(
source: Signal<T>,
transform: (value: T) => string | undefined | null,
fallback?: string,
): void {
const tabService = inject(TabService);
const tabId = injectTabId();
const log = logger({ fn: 'useTabSubtitle' });
effect(() => {
const value = source();
const subtitle = transform(value) ?? fallback;
if (subtitle !== undefined && subtitle !== null) {
untracked(() => {
const id = tabId();
if (id !== null && id !== undefined) {
tabService.patchTab(id, { subtitle });
log.debug('Tab subtitle updated', () => ({ tabId: id, subtitle }));
}
});
}
});
}
/**
* Sets up reactive tab subtitle from a query params signal.
* Specialized helper for search components that derive subtitle from query parameters.
*
* @param queryParamsSource - Signal providing query params (Record<string, string>)
* @param key - Query param key to extract subtitle from (default: 'main_qs')
* @param fallback - Fallback string when key is empty (default: 'Suche')
*
* @example
* // With Filter object - create a computed for query params:
* filterParams = computed(() => this.filter()?.getQueryParams());
* useQueryParamSubtitle(this.filterParams, 'main_qs', 'Kundensuche');
*
* // With custom key:
* useQueryParamSubtitle(this.filterParams, 'search_term', 'Artikelsuche');
*/
export function useQueryParamSubtitle(
queryParamsSource: Signal<Record<string, string> | undefined | null>,
key = 'main_qs',
fallback = 'Suche',
): void {
useTabSubtitle(queryParamsSource, (params) => params?.[key], fallback);
}

View File

@@ -23,10 +23,10 @@ import {
TabLocationHistory,
} from './schemas';
import { TAB_CONFIG } from './tab-config';
import { TabHistoryPruner } from './tab-history-pruning';
import { TabHistoryPruner } from './internal/tab-history-pruning';
import { computed, inject } from '@angular/core';
import { withDevtools } from '@angular-architects/ngrx-toolkit';
import { CORE_TAB_ID_GENERATOR } from './tab-id.generator';
import { CORE_TAB_ID_GENERATOR } from './internal/tab-id.generator';
import { withStorage, UserStorageProvider } from '@isa/core/storage';
import { logger } from '@isa/core/logging';
@@ -403,16 +403,17 @@ export const TabService = signalStore(
}));
},
/**
* Gets the current location for a tab.
* Gets the current location for a tab, validating and auto-correcting invalid indices.
*
* IMPORTANT: This method has a side effect - if index validation is enabled
* and an invalid index is detected, it will automatically correct the index
* in the store, triggering state updates and storage autosave.
* **Side Effect Warning:** If index validation is enabled (`config.enableIndexValidation`)
* and an invalid index is detected, this method will automatically correct the index
* in the store, triggering state updates and storage autosave. This behavior exists
* to maintain data integrity for corrupted tab history.
*
* @param id - The tab ID
* @returns The current location or null if tab doesn't exist or history is empty
*/
getCurrentLocation(id: number) {
getCurrentLocationWithValidation(id: number) {
const currentTab = store.entityMap()[id];
if (!currentTab) return null;
@@ -427,7 +428,7 @@ export const TabService = signalStore(
if (wasInvalid && store._config.enableIndexValidation) {
store._logger.warn(
'Invalid location index corrected in getCurrentLocation',
'Invalid location index corrected in getCurrentLocationWithValidation',
() => ({
tabId: id,
invalidIndex: currentLocation.current,

View File

@@ -79,6 +79,12 @@ export class ShellNavigationItemComponent {
return '';
}
// Validate SVG structure before bypassing security
const trimmed = icon.trim();
if (!trimmed.startsWith('<svg') || !trimmed.includes('</svg>')) {
return '';
}
return this.#sanitizer.bypassSecurityTrustHtml(icon);
});

View File

@@ -198,6 +198,7 @@ export const navigations: Array<NavigationGroup | NavigationItem> = [
() => {
const routeSignal = injectLabeledTabRoute('Remission', [
'remission',
'mandatory',
]);
const remissionStore = inject(RemissionStore);
return computed(() => ({

View File

@@ -79,3 +79,76 @@ a.compact button {
a.compact .close-icon {
font-size: 0.75rem;
}
/* Icon container */
.icon-container {
@apply relative size-5 shrink-0 flex items-center justify-center;
}
/* Clickable icon button */
.icon-button {
@apply relative size-5 flex items-center justify-center
rounded transition-colors duration-150
hover:bg-isa-neutral-300 focus:bg-isa-neutral-300
focus:outline-none focus-visible:ring-2 focus-visible:ring-isa-neutral-500;
}
/* SVG icon */
.icon-svg {
@apply size-4;
}
.icon-svg :deep(svg) {
@apply size-full;
}
/* Boolean indicator dot */
.indicator-dot {
@apply absolute -top-0.5 -right-0.5 size-2 rounded-full bg-isa-accent-red;
animation: pulse-in 0.3s ease-out;
}
/* Numeric indicator badge */
.indicator-badge {
@apply absolute -top-1 -right-1.5 min-w-4 h-4 px-1
flex items-center justify-center
rounded-full bg-isa-accent-red
text-isa-white text-[10px] font-semibold leading-none;
animation: pulse-in 0.3s ease-out;
}
/* Compact mode indicator */
.compact-indicator {
@apply absolute left-1 top-1/2 -translate-y-1/2
size-1.5 rounded-full bg-isa-accent-red;
animation: pulse-in 0.3s ease-out;
}
/* Pulse animation */
@keyframes pulse-in {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.4);
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.indicator-dot,
.indicator-badge,
.compact-indicator {
animation: none;
}
}
/* Hide icon container in compact mode */
a.compact .icon-container {
@apply hidden;
}

View File

@@ -1,29 +1,71 @@
@let _route = route();
@let _tab = tab();
@let _compact = compact();
@let _hasIcon = _tab.icon || showIndicator();
<a
class="w-full flex flex-row min-w-[10rem] max-w-[11.5rem] px-3 pt-2 justify-start items-start gap-2 rounded-t-2xl bg-isa-neutral-200 overflow-hidden transition-all duration-200"
[class.compact]="compact()"
[class.compact]="_compact"
[routerLink]="_route?.urlTree ?? '/'"
[title]="_route?.title ?? ''"
data-what="tab-link"
[attr.data-which]="tab().id"
[attr.data-which]="_tab.id"
[attr.aria-current]="active() ? 'page' : null"
>
<div class="grow min-w-0">
<div class="isa-text-caption-bold truncate w-full">
{{ tab().name }}
<!-- Icon/Indicator Container (expanded mode only) -->
@if (!_compact && _hasIcon) {
<div class="icon-container" data-what="tab-icon" [attr.data-which]="_tab.id">
@if (hasIconRoute()) {
<button
type="button"
(click)="navigateIconRoute($event)"
class="icon-button"
[attr.aria-label]="'Navigate from ' + _tab.name"
>
@if (_tab.icon) {
<div class="icon-svg" [innerHTML]="sanitizedIcon()"></div>
}
@if (showIndicator()) {
<span
[class]="indicatorText() ? 'indicator-badge' : 'indicator-dot'"
aria-hidden="true"
>{{ indicatorText() }}</span>
}
</button>
} @else {
@if (_tab.icon) {
<div class="icon-svg" [innerHTML]="sanitizedIcon()"></div>
}
@if (showIndicator()) {
<span
[class]="indicatorText() ? 'indicator-badge' : 'indicator-dot'"
aria-hidden="true"
>{{ indicatorText() }}</span>
}
}
</div>
<div class="subtitle" [class.collapsed]="compact()">
<span class="isa-text-caption-regular w-full">
{{ tab().subtitle }}
</span>
}
<!-- Compact mode indicator -->
@if (_compact && showIndicator()) {
<span class="compact-indicator" aria-hidden="true"></span>
}
<!-- Name and subtitle -->
<div class="grow min-w-0">
<div class="isa-text-caption-bold truncate w-full">{{ _tab.name }}</div>
<div class="subtitle" [class.collapsed]="_compact">
<span class="isa-text-caption-regular w-full">{{ _tab.subtitle }}</span>
</div>
</div>
<!-- Close button -->
<button
(click)="close($event)"
class="grow-0"
data-what="button"
data-which="close-tab"
[attr.aria-label]="'Tab schließen: ' + tab().name"
[attr.aria-label]="'Tab schließen: ' + _tab.name"
>
<ng-icon name="isaActionClose" class="close-icon"></ng-icon>
</button>

View File

@@ -5,6 +5,7 @@ import {
inject,
computed,
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { logger } from '@isa/core/logging';
import { Tab } from '@isa/core/tabs';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -42,6 +43,7 @@ export class ShellTabItemComponent {
#logger = logger({ component: 'ShellTabItemComponent' });
#router = inject(Router);
#tabService = inject(TabService);
#sanitizer = inject(DomSanitizer);
/** The tab entity to display. */
tab = input.required<Tab>();
@@ -58,7 +60,7 @@ export class ShellTabItemComponent {
/** The route for this tab, parsed as a UrlTree for proper auxiliary route handling. */
route = computed(() => {
const tab = this.tab();
const location = this.#tabService.getCurrentLocation(tab.id);
const location = this.#tabService.getCurrentLocationWithValidation(tab.id);
if (!location?.url) {
return null;
}
@@ -69,6 +71,57 @@ export class ShellTabItemComponent {
};
});
/**
* Sanitized HTML for the tab icon SVG.
* Returns empty string if no icon is set or if invalid SVG structure.
*/
sanitizedIcon = computed(() => {
const icon = this.tab().icon;
if (!icon) return '';
// Validate SVG structure before bypassing security
const trimmed = icon.trim();
if (!trimmed.startsWith('<svg') || !trimmed.includes('</svg>')) {
this.#logger.warn('Invalid SVG icon format', () => ({
tabId: this.tab().id,
iconPreview: icon.substring(0, 50),
}));
return '';
}
return this.#sanitizer.bypassSecurityTrustHtml(icon);
});
/**
* Whether to show an indicator badge/dot.
* True when indicator is truthy (boolean true or number > 0).
*/
showIndicator = computed(() => {
const indicator = this.tab().indicator;
if (indicator === null || indicator === undefined) return false;
if (typeof indicator === 'boolean') return indicator;
return indicator > 0;
});
/**
* Text to display for numeric indicators.
* Returns "9+" for numbers > 9, otherwise the number as string.
* Returns null for boolean indicators (dot only).
*/
indicatorText = computed(() => {
const indicator = this.tab().indicator;
if (typeof indicator !== 'number') return null;
return indicator > 9 ? '9+' : indicator.toString();
});
/**
* Whether the icon has a click route configured.
*/
hasIconRoute = computed(() => {
const iconRoute = this.tab().iconRoute;
return iconRoute !== null && iconRoute !== undefined;
});
/**
* Closes this tab and navigates to the previously active tab.
* If no previous tab exists, navigates to the root route.
@@ -84,7 +137,7 @@ export class ShellTabItemComponent {
const previousTab = this.#tabService.removeTab(tabId);
if (previousTab) {
const location = this.#tabService.getCurrentLocation(previousTab.id);
const location = this.#tabService.getCurrentLocationWithValidation(previousTab.id);
if (location?.url) {
await this.#router.navigateByUrl(location.url);
return;
@@ -96,4 +149,38 @@ export class ShellTabItemComponent {
this.#logger.error('Failed to close tab', error as Error, () => ({ tabId }));
}
}
/**
* Navigates to the icon route when the icon is clicked.
* Prevents the click from bubbling to the tab link.
*/
async navigateIconRoute(event: MouseEvent): Promise<void> {
event.stopPropagation();
event.preventDefault();
const iconRoute = this.tab().iconRoute;
if (!iconRoute) return;
const tabId = this.tab().id;
this.#logger.debug('Navigating via icon route', () => ({
tabId,
route: iconRoute.route,
}));
try {
const route =
typeof iconRoute.route === 'string'
? [iconRoute.route]
: iconRoute.route;
await this.#router.navigate(route as unknown[], {
queryParams: iconRoute.queryParams,
queryParamsHandling: iconRoute.queryParamsHandling || undefined,
});
} catch (error) {
this.#logger.error('Failed to navigate via icon route', error as Error, () => ({
tabId,
}));
}
}
}

View File

@@ -65,9 +65,6 @@
"@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/connectivity": ["libs/core/connectivity/src/index.ts"],