mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
♻️ 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:
@@ -1,5 +1,4 @@
|
|||||||
import { version } from '../../../../package.json';
|
import { version } from '../../../../package.json';
|
||||||
import { IsaTitleStrategy } from '@isa/common/title-management';
|
|
||||||
import {
|
import {
|
||||||
HTTP_INTERCEPTORS,
|
HTTP_INTERCEPTORS,
|
||||||
HttpInterceptorFn,
|
HttpInterceptorFn,
|
||||||
@@ -21,11 +20,7 @@ import {
|
|||||||
isDevMode,
|
isDevMode,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
import {
|
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||||
provideRouter,
|
|
||||||
TitleStrategy,
|
|
||||||
withComponentInputBinding,
|
|
||||||
} from '@angular/router';
|
|
||||||
import { ActionReducer, MetaReducer, provideStore } from '@ngrx/store';
|
import { ActionReducer, MetaReducer, provideStore } from '@ngrx/store';
|
||||||
import { provideStoreDevtools } from '@ngrx/store-devtools';
|
import { provideStoreDevtools } from '@ngrx/store-devtools';
|
||||||
|
|
||||||
@@ -58,14 +53,7 @@ import {
|
|||||||
} from '@adapter/scan';
|
} from '@adapter/scan';
|
||||||
import * as Commands from './commands';
|
import * as Commands from './commands';
|
||||||
import { NativeContainerService } from '@external/native-container';
|
import { NativeContainerService } from '@external/native-container';
|
||||||
import { ShellModule } from '@shared/shell';
|
|
||||||
import { IconModule } from '@shared/components/icon';
|
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 { NetworkStatusService } from '@isa/core/connectivity';
|
||||||
import { debounceTime, filter, firstValueFrom, switchMap } from 'rxjs';
|
import { debounceTime, filter, firstValueFrom, switchMap } from 'rxjs';
|
||||||
import { provideMatomo, withRouter, withRouteData } from 'ngx-matomo-client';
|
import { provideMatomo, withRouter, withRouteData } from 'ngx-matomo-client';
|
||||||
@@ -86,7 +74,6 @@ import { Store } from '@ngrx/store';
|
|||||||
import { OAuthService } from 'angular-oauth2-oidc';
|
import { OAuthService } from 'angular-oauth2-oidc';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
|
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
|
||||||
import { TabNavigationService } from '@isa/core/tabs';
|
|
||||||
|
|
||||||
// Domain modules
|
// Domain modules
|
||||||
import { provideDomainCheckout } from '@domain/checkout';
|
import { provideDomainCheckout } from '@domain/checkout';
|
||||||
@@ -103,6 +90,7 @@ import { PrintConfiguration } from '@generated/swagger/print-api';
|
|||||||
import { RemiConfiguration } from '@generated/swagger/inventory-api';
|
import { RemiConfiguration } from '@generated/swagger/inventory-api';
|
||||||
import { WwsConfiguration } from '@generated/swagger/wws-api';
|
import { WwsConfiguration } from '@generated/swagger/wws-api';
|
||||||
import { UiIconModule } from '@ui/icon';
|
import { UiIconModule } from '@ui/icon';
|
||||||
|
import { provideCoreTabs } from '@isa/core/tabs';
|
||||||
|
|
||||||
// --- Store Configuration ---
|
// --- Store Configuration ---
|
||||||
|
|
||||||
@@ -139,7 +127,7 @@ function appInitializerFactory(_config: Config, injector: Injector) {
|
|||||||
return async () => {
|
return async () => {
|
||||||
const logger = loggerFactory(() => ({ service: 'AppInitializer' }));
|
const logger = loggerFactory(() => ({ service: 'AppInitializer' }));
|
||||||
const statusElement = document.querySelector('#init-status');
|
const statusElement = document.querySelector('#init-status');
|
||||||
const laoderElement = document.querySelector('#init-loader');
|
const loaderElement = document.querySelector('#init-loader');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info('Starting application initialization');
|
logger.info('Starting application initialization');
|
||||||
@@ -221,14 +209,11 @@ function appInitializerFactory(_config: Config, injector: Injector) {
|
|||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
userStorage.set('store', state);
|
userStorage.set('store', state);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Application initialization completed');
|
|
||||||
injector.get(TabNavigationService).init();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Application initialization failed', error as Error, () => ({
|
logger.error('Application initialization failed', error as Error, () => ({
|
||||||
message: (error as Error).message,
|
message: (error as Error).message,
|
||||||
}));
|
}));
|
||||||
laoderElement.remove();
|
loaderElement?.remove();
|
||||||
statusElement.classList.add('text-xl');
|
statusElement.classList.add('text-xl');
|
||||||
statusElement.innerHTML +=
|
statusElement.innerHTML +=
|
||||||
'⚡<br><br><b>Fehler bei der Initialisierung</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br><br>';
|
'⚡<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),
|
provideUserSubFactory(USER_SUB_FACTORY),
|
||||||
|
|
||||||
// Title strategy
|
// Title strategy
|
||||||
{ provide: TitleStrategy, useClass: IsaTitleStrategy },
|
provideCoreTabs(),
|
||||||
|
|
||||||
// Import providers from NgModules
|
// Import providers from NgModules
|
||||||
importProvidersFrom(
|
importProvidersFrom(
|
||||||
|
|||||||
@@ -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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { AuthService } from '@core/auth';
|
import { AuthService } from '@core/auth';
|
||||||
|
|
||||||
@@ -7,25 +12,24 @@ import { AuthService } from '@core/auth';
|
|||||||
templateUrl: 'token-login.component.html',
|
templateUrl: 'token-login.component.html',
|
||||||
styleUrls: ['token-login.component.scss'],
|
styleUrls: ['token-login.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
})
|
})
|
||||||
export class TokenLoginComponent implements OnInit {
|
export class TokenLoginComponent implements OnInit {
|
||||||
constructor(
|
readonly #route = inject(ActivatedRoute);
|
||||||
private _route: ActivatedRoute,
|
readonly #authService = inject(AuthService);
|
||||||
private _authService: AuthService,
|
readonly #router = inject(Router);
|
||||||
private _router: Router,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (
|
if (
|
||||||
this._route.snapshot.params.token &&
|
this.#route.snapshot.params.token &&
|
||||||
!this._authService.isAuthenticated()
|
!this.#authService.isAuthenticated()
|
||||||
) {
|
) {
|
||||||
this._authService.setKeyCardToken(this._route.snapshot.params.token);
|
this.#authService.setKeyCardToken(this.#route.snapshot.params.token);
|
||||||
this._authService.login();
|
this.#authService.login();
|
||||||
} else if (!this._authService.isAuthenticated()) {
|
} else if (!this.#authService.isAuthenticated()) {
|
||||||
this._authService.login();
|
this.#authService.login();
|
||||||
} else if (this._authService.isAuthenticated()) {
|
} else if (this.#authService.isAuthenticated()) {
|
||||||
this._router.navigate(['/']);
|
this.#router.navigate(['/']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
OrderListItemDTO,
|
OrderListItemDTO,
|
||||||
OrderService,
|
OrderService,
|
||||||
ReceiptService,
|
ReceiptService,
|
||||||
|
ReceiptType,
|
||||||
StatusValues,
|
StatusValues,
|
||||||
StockStatusCodeService,
|
StockStatusCodeService,
|
||||||
ValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO,
|
ValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO,
|
||||||
@@ -68,7 +69,10 @@ export class DomainOmsService {
|
|||||||
return this.receiptService
|
return this.receiptService
|
||||||
.ReceiptGetReceiptsByOrderItemSubset({
|
.ReceiptGetReceiptsByOrderItemSubset({
|
||||||
payload: {
|
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,
|
ids: orderItemSubsetIds,
|
||||||
eagerLoading: 1,
|
eagerLoading: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import {
|
|||||||
NavigateAfterRewardSelection,
|
NavigateAfterRewardSelection,
|
||||||
RewardSelectionPopUpService,
|
RewardSelectionPopUpService,
|
||||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { useTabSubtitle } from '@isa/core/tabs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'page-article-details',
|
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(
|
constructor(
|
||||||
public readonly applicationService: ApplicationService,
|
public readonly applicationService: ApplicationService,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
@@ -316,7 +322,9 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
|
|||||||
private _router: Router,
|
private _router: Router,
|
||||||
private _domainCheckoutService: DomainCheckoutService,
|
private _domainCheckoutService: DomainCheckoutService,
|
||||||
private _store: Store,
|
private _store: Store,
|
||||||
) {}
|
) {
|
||||||
|
useTabSubtitle(this.item, (item) => item?.product?.name);
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const processIdSubscription = this.activatedRoute.parent.params
|
const processIdSubscription = this.activatedRoute.parent.params
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
import { ApplicationService } from '@core/application';
|
import { ApplicationService } from '@core/application';
|
||||||
import { EnvironmentService } from '@core/environment';
|
import { EnvironmentService } from '@core/environment';
|
||||||
import { Observable, Subject } from 'rxjs';
|
import { Observable, Subject } from 'rxjs';
|
||||||
@@ -7,6 +14,8 @@ import { ArticleSearchService } from '../article-search.store';
|
|||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { ProductCatalogNavigationService } from '@shared/services/navigation';
|
import { ProductCatalogNavigationService } from '@shared/services/navigation';
|
||||||
import { Filter, FilterComponent } from '@shared/components/filter';
|
import { Filter, FilterComponent } from '@shared/components/filter';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { useQueryParamSubtitle } from '@isa/core/tabs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'page-article-search-filter',
|
selector: 'page-article-search-filter',
|
||||||
@@ -16,48 +25,68 @@ import { Filter, FilterComponent } from '@shared/components/filter';
|
|||||||
standalone: false,
|
standalone: false,
|
||||||
})
|
})
|
||||||
export class ArticleSearchFilterComponent implements OnInit, OnDestroy {
|
export class ArticleSearchFilterComponent implements OnInit, OnDestroy {
|
||||||
_processId$ = this._activatedRoute.parent.data.pipe(map((data) => Number(data.processId)));
|
_processId$ = this._activatedRoute.parent.data.pipe(
|
||||||
|
map((data) => Number(data.processId)),
|
||||||
|
);
|
||||||
|
processId = toSignal(this._processId$);
|
||||||
|
|
||||||
fetching$: Observable<boolean> = this.articleSearch.fetching$;
|
fetching$: Observable<boolean> = this.articleSearch.fetching$;
|
||||||
|
|
||||||
filter$: Observable<Filter> = this.articleSearch.filter$;
|
filter$: Observable<Filter> = this.articleSearch.filter$;
|
||||||
|
|
||||||
|
filter = toSignal(this.filter$);
|
||||||
|
|
||||||
|
filterParams = computed(() => this.filter()?.getQueryParams());
|
||||||
|
|
||||||
searchboxHint$ = this.articleSearch.searchboxHint$;
|
searchboxHint$ = this.articleSearch.searchboxHint$;
|
||||||
|
|
||||||
@ViewChild(FilterComponent, { static: false })
|
@ViewChild(FilterComponent, { static: false })
|
||||||
uiFilterComponent: FilterComponent;
|
uiFilterComponent: FilterComponent;
|
||||||
|
|
||||||
showFilter: boolean = false;
|
showFilter = false;
|
||||||
|
|
||||||
get isTablet() {
|
get isTablet() {
|
||||||
return this._environment.matchTablet();
|
return this._environment.matchTablet();
|
||||||
}
|
}
|
||||||
|
|
||||||
get showFilterClose$() {
|
get showFilterClose$() {
|
||||||
return this._environment.matchDesktopLarge$.pipe(map((matches) => !(matches && this.sideOutlet === 'search')));
|
return this._environment.matchDesktopLarge$.pipe(
|
||||||
|
map((matches) => !(matches && this.sideOutlet === 'search')),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get sideOutlet() {
|
get sideOutlet() {
|
||||||
return this._activatedRoute?.parent?.children?.find((childRoute) => childRoute?.outlet === 'side')?.snapshot
|
return this._activatedRoute?.parent?.children?.find(
|
||||||
?.routeConfig?.path;
|
(childRoute) => childRoute?.outlet === 'side',
|
||||||
|
)?.snapshot?.routeConfig?.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
get primaryOutlet() {
|
get primaryOutlet() {
|
||||||
return this._activatedRoute?.parent?.children?.find((childRoute) => childRoute?.outlet === 'primary')?.snapshot
|
return this._activatedRoute?.parent?.children?.find(
|
||||||
?.routeConfig?.path;
|
(childRoute) => childRoute?.outlet === 'primary',
|
||||||
|
)?.snapshot?.routeConfig?.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
get closeFilterRoute() {
|
get closeFilterRoute() {
|
||||||
const processId = Number(this._activatedRoute?.parent?.snapshot?.data?.processId);
|
const processId = Number(
|
||||||
|
this._activatedRoute?.parent?.snapshot?.data?.processId,
|
||||||
|
);
|
||||||
const itemId = this._activatedRoute.snapshot.params.id;
|
const itemId = this._activatedRoute.snapshot.params.id;
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
if (this.sideOutlet === 'search') {
|
if (this.sideOutlet === 'search') {
|
||||||
return this._navigationService.getArticleSearchBasePath(processId).path;
|
return this._navigationService.getArticleSearchBasePath(processId).path;
|
||||||
} else if (this.primaryOutlet === 'results' || this.sideOutlet === 'results') {
|
} else if (
|
||||||
return this._navigationService.getArticleSearchResultsPath(processId).path;
|
this.primaryOutlet === 'results' ||
|
||||||
|
this.sideOutlet === 'results'
|
||||||
|
) {
|
||||||
|
return this._navigationService.getArticleSearchResultsPath(processId)
|
||||||
|
.path;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return this._navigationService.getArticleDetailsPath({ processId, itemId }).path;
|
return this._navigationService.getArticleDetailsPath({
|
||||||
|
processId,
|
||||||
|
itemId,
|
||||||
|
}).path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,24 +98,39 @@ export class ArticleSearchFilterComponent implements OnInit, OnDestroy {
|
|||||||
private _activatedRoute: ActivatedRoute,
|
private _activatedRoute: ActivatedRoute,
|
||||||
public application: ApplicationService,
|
public application: ApplicationService,
|
||||||
private _navigationService: ProductCatalogNavigationService,
|
private _navigationService: ProductCatalogNavigationService,
|
||||||
) {}
|
) {
|
||||||
|
useQueryParamSubtitle(this.filterParams, 'main_qs', 'Filter');
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.showFilter = this.sideOutlet !== 'search';
|
this.showFilter = this.sideOutlet !== 'search';
|
||||||
this._activatedRoute.queryParams
|
this._activatedRoute.queryParams
|
||||||
.pipe(takeUntil(this._onDestroy$))
|
.pipe(takeUntil(this._onDestroy$))
|
||||||
.subscribe(async (queryParams) => await this.articleSearch.setDefaultFilter(queryParams));
|
.subscribe(
|
||||||
|
async (queryParams) =>
|
||||||
|
await this.articleSearch.setDefaultFilter(queryParams),
|
||||||
|
);
|
||||||
|
|
||||||
// #4143 To make Splitscreen Search and Filter work combined
|
// #4143 To make Splitscreen Search and Filter work combined
|
||||||
this.articleSearch.searchStarted.pipe(takeUntil(this._onDestroy$)).subscribe(async (_) => {
|
this.articleSearch.searchStarted
|
||||||
|
.pipe(takeUntil(this._onDestroy$))
|
||||||
|
.subscribe(async (_) => {
|
||||||
let queryParams = {
|
let queryParams = {
|
||||||
...this.articleSearch.filter.getQueryParams(),
|
...this.articleSearch.filter.getQueryParams(),
|
||||||
...this.cleanupQueryParams(this.uiFilterComponent?.uiFilter?.getQueryParams()),
|
...this.cleanupQueryParams(
|
||||||
|
this.uiFilterComponent?.uiFilter?.getQueryParams(),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Always override query if not in tablet mode
|
// Always override query if not in tablet mode
|
||||||
if (!!this.articleSearch.filter.getQueryParams()?.main_qs && !this.isTablet) {
|
if (
|
||||||
queryParams = { ...queryParams, main_qs: this.articleSearch.filter.getQueryParams()?.main_qs };
|
!!this.articleSearch.filter.getQueryParams()?.main_qs &&
|
||||||
|
!this.isTablet
|
||||||
|
) {
|
||||||
|
queryParams = {
|
||||||
|
...queryParams,
|
||||||
|
main_qs: this.articleSearch.filter.getQueryParams()?.main_qs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.articleSearch.setDefaultFilter(queryParams);
|
await this.articleSearch.setDefaultFilter(queryParams);
|
||||||
@@ -107,7 +151,9 @@ export class ArticleSearchFilterComponent implements OnInit, OnDestroy {
|
|||||||
})
|
})
|
||||||
.navigate();
|
.navigate();
|
||||||
} else {
|
} else {
|
||||||
await this._navigationService.getArticleSearchResultsPath(processId, { queryParams: params }).navigate();
|
await this._navigationService
|
||||||
|
.getArticleSearchResultsPath(processId, { queryParams: params })
|
||||||
|
.navigate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -136,7 +182,9 @@ export class ArticleSearchFilterComponent implements OnInit, OnDestroy {
|
|||||||
})
|
})
|
||||||
.navigate();
|
.navigate();
|
||||||
} else {
|
} else {
|
||||||
await this._navigationService.getArticleSearchResultsPath(processId, { queryParams: params }).navigate();
|
await this._navigationService
|
||||||
|
.getArticleSearchResultsPath(processId, { queryParams: params })
|
||||||
|
.navigate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
TrackByFunction,
|
TrackByFunction,
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
inject,
|
inject,
|
||||||
|
computed,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { ApplicationService } from '@core/application';
|
import { ApplicationService } from '@core/application';
|
||||||
@@ -40,6 +41,8 @@ import {
|
|||||||
import { DomainAvailabilityService, ItemData } from '@domain/availability';
|
import { DomainAvailabilityService, ItemData } from '@domain/availability';
|
||||||
import { asapScheduler } from 'rxjs';
|
import { asapScheduler } from 'rxjs';
|
||||||
import { ShellService } from '@shared/shell';
|
import { ShellService } from '@shared/shell';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { useQueryParamSubtitle } from '@isa/core/tabs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'page-search-results',
|
selector: 'page-search-results',
|
||||||
@@ -134,6 +137,12 @@ export class ArticleSearchResultsComponent
|
|||||||
|
|
||||||
scale$ = this.shellService.scale$;
|
scale$ = this.shellService.scale$;
|
||||||
|
|
||||||
|
processId = toSignal(this.application.activatedProcessId$);
|
||||||
|
|
||||||
|
filter = toSignal(this.filter$);
|
||||||
|
|
||||||
|
filterParams = computed(() => this.filter()?.getQueryParams());
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public searchService: ArticleSearchService,
|
public searchService: ArticleSearchService,
|
||||||
public route: ActivatedRoute,
|
public route: ActivatedRoute,
|
||||||
@@ -146,7 +155,9 @@ export class ArticleSearchResultsComponent
|
|||||||
private _navigationService: ProductCatalogNavigationService,
|
private _navigationService: ProductCatalogNavigationService,
|
||||||
private _availability: DomainAvailabilityService,
|
private _availability: DomainAvailabilityService,
|
||||||
private _router: Router,
|
private _router: Router,
|
||||||
) {}
|
) {
|
||||||
|
useQueryParamSubtitle(this.filterParams, 'main_qs', 'Artikelsuche');
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.subscriptions.add(
|
this.subscriptions.add(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'filter',
|
path: 'filter',
|
||||||
component: ArticleSearchFilterComponent,
|
component: ArticleSearchFilterComponent,
|
||||||
title: 'Artikelsuche - Filter',
|
title: 'Artikelsuche',
|
||||||
data: {
|
data: {
|
||||||
matomo: {
|
matomo: {
|
||||||
title: 'Artikelsuche - Filter',
|
title: 'Artikelsuche - Filter',
|
||||||
@@ -32,7 +32,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'filter/:id',
|
path: 'filter/:id',
|
||||||
component: ArticleSearchFilterComponent,
|
component: ArticleSearchFilterComponent,
|
||||||
title: 'Artikelsuche - Filter',
|
title: 'Artikelsuche',
|
||||||
data: {
|
data: {
|
||||||
matomo: {
|
matomo: {
|
||||||
title: 'Artikelsuche - Filter',
|
title: 'Artikelsuche - Filter',
|
||||||
@@ -59,7 +59,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'results',
|
path: 'results',
|
||||||
component: ArticleSearchResultsComponent,
|
component: ArticleSearchResultsComponent,
|
||||||
title: 'Artikelsuche - Trefferliste',
|
title: 'Artikelsuche',
|
||||||
data: {
|
data: {
|
||||||
matomo: {
|
matomo: {
|
||||||
title: 'Artikelsuche - Trefferliste',
|
title: 'Artikelsuche - Trefferliste',
|
||||||
@@ -70,7 +70,7 @@ const routes: Routes = [
|
|||||||
path: 'results',
|
path: 'results',
|
||||||
component: ArticleSearchResultsComponent,
|
component: ArticleSearchResultsComponent,
|
||||||
outlet: 'side',
|
outlet: 'side',
|
||||||
title: 'Artikelsuche - Trefferliste',
|
title: 'Artikelsuche',
|
||||||
data: {
|
data: {
|
||||||
matomo: {
|
matomo: {
|
||||||
title: 'Artikelsuche - Trefferliste',
|
title: 'Artikelsuche - Trefferliste',
|
||||||
@@ -81,7 +81,7 @@ const routes: Routes = [
|
|||||||
path: 'results/:id',
|
path: 'results/:id',
|
||||||
component: ArticleSearchResultsComponent,
|
component: ArticleSearchResultsComponent,
|
||||||
outlet: 'side',
|
outlet: 'side',
|
||||||
title: 'Artikelsuche - Artikeldetails',
|
title: 'Artikelsuche',
|
||||||
data: {
|
data: {
|
||||||
matomo: {
|
matomo: {
|
||||||
title: 'Artikelsuche - Artikeldetails',
|
title: 'Artikelsuche - Artikeldetails',
|
||||||
@@ -92,7 +92,7 @@ const routes: Routes = [
|
|||||||
path: 'results/:ean/ean',
|
path: 'results/:ean/ean',
|
||||||
component: ArticleSearchResultsComponent,
|
component: ArticleSearchResultsComponent,
|
||||||
outlet: 'side',
|
outlet: 'side',
|
||||||
title: 'Artikelsuche - Artikeldetails (EAN)',
|
title: 'Artikelsuche',
|
||||||
data: {
|
data: {
|
||||||
matomo: {
|
matomo: {
|
||||||
title: 'Artikelsuche - Artikeldetails (EAN)',
|
title: 'Artikelsuche - Artikeldetails (EAN)',
|
||||||
@@ -102,7 +102,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'details/:id',
|
path: 'details/:id',
|
||||||
component: ArticleDetailsComponent,
|
component: ArticleDetailsComponent,
|
||||||
title: 'Artikelsuche - Artikeldetails (ID)',
|
title: 'Artikelsuche',
|
||||||
data: {
|
data: {
|
||||||
matomo: {
|
matomo: {
|
||||||
title: 'Artikelsuche - Artikeldetails (ID)',
|
title: 'Artikelsuche - Artikeldetails (ID)',
|
||||||
@@ -112,7 +112,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'details/:ean/ean',
|
path: 'details/:ean/ean',
|
||||||
component: ArticleDetailsComponent,
|
component: ArticleDetailsComponent,
|
||||||
title: 'Artikelsuche - Artikeldetails (EAN)',
|
title: 'Artikelsuche',
|
||||||
data: {
|
data: {
|
||||||
matomo: {
|
matomo: {
|
||||||
title: 'Artikelsuche - Artikeldetails (EAN)',
|
title: 'Artikelsuche - Artikeldetails (EAN)',
|
||||||
|
|||||||
@@ -3,15 +3,28 @@ import {
|
|||||||
emailNotificationValidator,
|
emailNotificationValidator,
|
||||||
mobileNotificationValidator,
|
mobileNotificationValidator,
|
||||||
} from '@shared/components/notification-channel-control';
|
} from '@shared/components/notification-channel-control';
|
||||||
import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms';
|
import {
|
||||||
|
UntypedFormBuilder,
|
||||||
|
UntypedFormControl,
|
||||||
|
UntypedFormGroup,
|
||||||
|
} from '@angular/forms';
|
||||||
import { combineLatest } from 'rxjs';
|
import { combineLatest } from 'rxjs';
|
||||||
import { CheckoutReviewStore } from '../checkout-review.store';
|
import { CheckoutReviewStore } from '../checkout-review.store';
|
||||||
import { first, map, switchMap, takeUntil, withLatestFrom } from 'rxjs/operators';
|
import {
|
||||||
|
first,
|
||||||
|
map,
|
||||||
|
switchMap,
|
||||||
|
takeUntil,
|
||||||
|
withLatestFrom,
|
||||||
|
} from 'rxjs/operators';
|
||||||
import { ApplicationService } from '@core/application';
|
import { ApplicationService } from '@core/application';
|
||||||
import { DomainCheckoutService } from '@domain/checkout';
|
import { DomainCheckoutService } from '@domain/checkout';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { BuyerDTO, NotificationChannel } from '@generated/swagger/checkout-api';
|
import { BuyerDTO, NotificationChannel } from '@generated/swagger/checkout-api';
|
||||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
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({
|
@Component({
|
||||||
selector: 'page-checkout-review-details',
|
selector: 'page-checkout-review-details',
|
||||||
@@ -30,40 +43,60 @@ export class CheckoutReviewDetailsComponent implements OnInit {
|
|||||||
payer$ = this._store.payer$;
|
payer$ = this._store.payer$;
|
||||||
buyer$ = this._store.buyer$;
|
buyer$ = this._store.buyer$;
|
||||||
|
|
||||||
showNotificationChannels$ = combineLatest([this._store.shoppingCartItems$, this.payer$, this.buyer$]).pipe(
|
showNotificationChannels$ = combineLatest([
|
||||||
|
this._store.shoppingCartItems$,
|
||||||
|
this.payer$,
|
||||||
|
this.buyer$,
|
||||||
|
]).pipe(
|
||||||
takeUntil(this._store.orderCompleted),
|
takeUntil(this._store.orderCompleted),
|
||||||
map(
|
map(
|
||||||
([items, payer, buyer]) =>
|
([items, payer, buyer]) =>
|
||||||
(!!payer || !!buyer) &&
|
(!!payer || !!buyer) &&
|
||||||
items.some((item) => item.features?.orderType === 'Rücklage' || item.features?.orderType === 'Abholung'),
|
items.some(
|
||||||
|
(item) =>
|
||||||
|
item.features?.orderType === 'Rücklage' ||
|
||||||
|
item.features?.orderType === 'Abholung',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
notificationChannel$ = this._application.activatedProcessId$.pipe(
|
notificationChannel$ = this._application.activatedProcessId$.pipe(
|
||||||
takeUntil(this._store.orderCompleted),
|
takeUntil(this._store.orderCompleted),
|
||||||
switchMap((processId) => this._domainCheckoutService.getNotificationChannels({ processId })),
|
switchMap((processId) =>
|
||||||
|
this._domainCheckoutService.getNotificationChannels({ processId }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
communicationDetails$ = this._application.activatedProcessId$.pipe(
|
communicationDetails$ = this._application.activatedProcessId$.pipe(
|
||||||
takeUntil(this._store.orderCompleted),
|
takeUntil(this._store.orderCompleted),
|
||||||
switchMap((processId) => this._domainCheckoutService.getBuyerCommunicationDetails({ processId })),
|
switchMap((processId) =>
|
||||||
map((communicationDetails) => communicationDetails ?? { email: undefined, mobile: undefined }),
|
this._domainCheckoutService.getBuyerCommunicationDetails({ processId }),
|
||||||
|
),
|
||||||
|
map(
|
||||||
|
(communicationDetails) =>
|
||||||
|
communicationDetails ?? { email: undefined, mobile: undefined },
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
specialComment$ = this._application.activatedProcessId$.pipe(
|
specialComment$ = this._application.activatedProcessId$.pipe(
|
||||||
switchMap((processId) => this._domainCheckoutService.getSpecialComment({ processId })),
|
switchMap((processId) =>
|
||||||
|
this._domainCheckoutService.getSpecialComment({ processId }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
shippingAddress$ = this._application.activatedProcessId$.pipe(
|
shippingAddress$ = this._application.activatedProcessId$.pipe(
|
||||||
takeUntil(this._store.orderCompleted),
|
takeUntil(this._store.orderCompleted),
|
||||||
switchMap((processId) => this._domainCheckoutService.getShippingAddress({ processId })),
|
switchMap((processId) =>
|
||||||
|
this._domainCheckoutService.getShippingAddress({ processId }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
showAddresses$ = this._store.shoppingCartItems$.pipe(
|
showAddresses$ = this._store.shoppingCartItems$.pipe(
|
||||||
takeUntil(this._store.orderCompleted),
|
takeUntil(this._store.orderCompleted),
|
||||||
withLatestFrom(this.customerFeatures$, this.payer$, this.shippingAddress$),
|
withLatestFrom(this.customerFeatures$, this.payer$, this.shippingAddress$),
|
||||||
map(([items, customerFeatures, payer, shippingAddress]) => {
|
map(([items, customerFeatures, payer, shippingAddress]) => {
|
||||||
const hasShippingOrBillingAddresses = !!payer?.address || !!shippingAddress;
|
const hasShippingOrBillingAddresses =
|
||||||
|
!!payer?.address || !!shippingAddress;
|
||||||
const hasShippingFeature = items.some(
|
const hasShippingFeature = items.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.features?.orderType === 'Versand' ||
|
item.features?.orderType === 'Versand' ||
|
||||||
@@ -73,19 +106,27 @@ export class CheckoutReviewDetailsComponent implements OnInit {
|
|||||||
|
|
||||||
const isB2bCustomer = !!customerFeatures?.b2b;
|
const isB2bCustomer = !!customerFeatures?.b2b;
|
||||||
|
|
||||||
return hasShippingOrBillingAddresses && (hasShippingFeature || isB2bCustomer);
|
return (
|
||||||
|
hasShippingOrBillingAddresses && (hasShippingFeature || isB2bCustomer)
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
notificationChannelLoading$ = this._store.notificationChannelLoading$;
|
notificationChannelLoading$ = this._store.notificationChannelLoading$;
|
||||||
|
|
||||||
|
processId = toSignal(this._application.activatedProcessId$);
|
||||||
|
|
||||||
|
buyer = toSignal(this.buyer$);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _fb: UntypedFormBuilder,
|
private _fb: UntypedFormBuilder,
|
||||||
private _store: CheckoutReviewStore,
|
private _store: CheckoutReviewStore,
|
||||||
private _application: ApplicationService,
|
private _application: ApplicationService,
|
||||||
private _domainCheckoutService: DomainCheckoutService,
|
private _domainCheckoutService: DomainCheckoutService,
|
||||||
private _router: Router,
|
private _router: Router,
|
||||||
) {}
|
) {
|
||||||
|
useTabSubtitle(this.buyer, getCustomerName);
|
||||||
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
await this.initNotificationsControl();
|
await this.initNotificationsControl();
|
||||||
@@ -93,8 +134,12 @@ export class CheckoutReviewDetailsComponent implements OnInit {
|
|||||||
|
|
||||||
async initNotificationsControl() {
|
async initNotificationsControl() {
|
||||||
const fb = this._fb;
|
const fb = this._fb;
|
||||||
const notificationChannel = await this.notificationChannel$.pipe(first()).toPromise();
|
const notificationChannel = await this.notificationChannel$
|
||||||
const communicationDetails = await this.communicationDetails$.pipe(first()).toPromise();
|
.pipe(first())
|
||||||
|
.toPromise();
|
||||||
|
const communicationDetails = await this.communicationDetails$
|
||||||
|
.pipe(first())
|
||||||
|
.toPromise();
|
||||||
|
|
||||||
let selectedNotificationChannel = 0;
|
let selectedNotificationChannel = 0;
|
||||||
if ((notificationChannel & 1) === 1 && communicationDetails.email) {
|
if ((notificationChannel & 1) === 1 && communicationDetails.email) {
|
||||||
@@ -129,7 +174,10 @@ export class CheckoutReviewDetailsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAgentComment(agentComment: string) {
|
setAgentComment(agentComment: string) {
|
||||||
this._domainCheckoutService.setSpecialComment({ processId: this._application.activatedProcessId, agentComment });
|
this._domainCheckoutService.setSpecialComment({
|
||||||
|
processId: this._application.activatedProcessId,
|
||||||
|
agentComment,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNotifications(notificationChannels?: NotificationChannel[]) {
|
updateNotifications(notificationChannels?: NotificationChannel[]) {
|
||||||
@@ -138,7 +186,10 @@ export class CheckoutReviewDetailsComponent implements OnInit {
|
|||||||
|
|
||||||
getNameFromBuyer(buyer: BuyerDTO): { value: string; label: string } {
|
getNameFromBuyer(buyer: BuyerDTO): { value: string; label: string } {
|
||||||
if (buyer?.lastName && buyer?.firstName) {
|
if (buyer?.lastName && buyer?.firstName) {
|
||||||
return { value: `${buyer?.lastName}, ${buyer?.firstName}`, label: 'Nachname, Vorname' };
|
return {
|
||||||
|
value: `${buyer?.lastName}, ${buyer?.firstName}`,
|
||||||
|
label: 'Nachname, Vorname',
|
||||||
|
};
|
||||||
} else if (buyer?.lastName) {
|
} else if (buyer?.lastName) {
|
||||||
return { value: buyer?.lastName, label: 'Nachname, Vorname' };
|
return { value: buyer?.lastName, label: 'Nachname, Vorname' };
|
||||||
} else if (buyer?.firstName) {
|
} else if (buyer?.firstName) {
|
||||||
@@ -152,7 +203,10 @@ export class CheckoutReviewDetailsComponent implements OnInit {
|
|||||||
|
|
||||||
async changeAddress() {
|
async changeAddress() {
|
||||||
const processId = this._application.activatedProcessId;
|
const processId = this._application.activatedProcessId;
|
||||||
const customer = await this._domainCheckoutService.getBuyer({ processId }).pipe(first()).toPromise();
|
const customer = await this._domainCheckoutService
|
||||||
|
.getBuyer({ processId })
|
||||||
|
.pipe(first())
|
||||||
|
.toPromise();
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
this.navigateToCustomerSearch(processId);
|
this.navigateToCustomerSearch(processId);
|
||||||
return;
|
return;
|
||||||
@@ -172,16 +226,27 @@ export class CheckoutReviewDetailsComponent implements OnInit {
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
switchMap((customerFeatures) => {
|
switchMap((customerFeatures) => {
|
||||||
return this._domainCheckoutService.canSetCustomer({ processId, customerFeatures });
|
return this._domainCheckoutService.canSetCustomer({
|
||||||
|
processId,
|
||||||
|
customerFeatures,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.toPromise();
|
.toPromise();
|
||||||
|
|
||||||
this._router.navigate(['/kunde', this._application.activatedProcessId, 'customer', 'search'], {
|
this._router.navigate(
|
||||||
|
['/kunde', this._application.activatedProcessId, 'customer', 'search'],
|
||||||
|
{
|
||||||
queryParams: { filter_customertype: response.filter.customertype },
|
queryParams: { filter_customertype: response.filter.customertype },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this._router.navigate(['/kunde', this._application.activatedProcessId, 'customer', 'search']);
|
this._router.navigate([
|
||||||
|
'/kunde',
|
||||||
|
this._application.activatedProcessId,
|
||||||
|
'customer',
|
||||||
|
'search',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ import { EnvironmentService } from '@core/environment';
|
|||||||
import { SendOrderConfirmationModalService } from '@modal/send-order-confirmation';
|
import { SendOrderConfirmationModalService } from '@modal/send-order-confirmation';
|
||||||
import { ToasterService } from '@shared/shell';
|
import { ToasterService } from '@shared/shell';
|
||||||
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
|
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
|
||||||
import { injectTabId } from '@isa/core/tabs';
|
import { injectTabId, useTabSubtitle } from '@isa/core/tabs';
|
||||||
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
|
import {
|
||||||
|
getCustomerName,
|
||||||
|
PrimaryCustomerCardResource,
|
||||||
|
} from '@isa/crm/data-access';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'page-checkout-summary',
|
selector: 'page-checkout-summary',
|
||||||
@@ -279,6 +283,8 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
expanded: boolean[] = [];
|
expanded: boolean[] = [];
|
||||||
|
|
||||||
|
displayOrders = toSignal(this.displayOrders$);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private domainCheckoutService: DomainCheckoutService,
|
private domainCheckoutService: DomainCheckoutService,
|
||||||
private customerService: CrmCustomerService,
|
private customerService: CrmCustomerService,
|
||||||
@@ -297,6 +303,12 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
|
|||||||
private _environmentService: EnvironmentService,
|
private _environmentService: EnvironmentService,
|
||||||
private _cdr: ChangeDetectorRef,
|
private _cdr: ChangeDetectorRef,
|
||||||
) {
|
) {
|
||||||
|
// Tab subtitle with null-safe access to first order's buyer
|
||||||
|
useTabSubtitle(this.displayOrders, (orders) => {
|
||||||
|
const firstOrder = orders?.length > 0 ? orders[0] : null;
|
||||||
|
return firstOrder ? getCustomerName(firstOrder.buyer) : null;
|
||||||
|
});
|
||||||
|
|
||||||
this.breadcrumb
|
this.breadcrumb
|
||||||
.getBreadcrumbsByKeyAndTags$(this.applicationService.activatedProcessId, [
|
.getBreadcrumbsByKeyAndTags$(this.applicationService.activatedProcessId, [
|
||||||
'checkout',
|
'checkout',
|
||||||
|
|||||||
@@ -14,24 +14,24 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'details',
|
path: 'details',
|
||||||
component: CheckoutReviewDetailsComponent,
|
component: CheckoutReviewDetailsComponent,
|
||||||
title: 'Bestelldetails',
|
title: 'Warenkorb',
|
||||||
outlet: 'side',
|
outlet: 'side',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'review',
|
path: 'review',
|
||||||
component: CheckoutReviewComponent,
|
component: CheckoutReviewComponent,
|
||||||
title: 'Bestellung überprüfen',
|
title: 'Warenkorb',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'summary',
|
path: 'summary',
|
||||||
component: CheckoutSummaryComponent,
|
component: CheckoutSummaryComponent,
|
||||||
title: 'Bestellübersicht',
|
title: 'Bestellbestätigung',
|
||||||
canDeactivate: [canDeactivateTabCleanup],
|
canDeactivate: [canDeactivateTabCleanup],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'summary/:orderIds',
|
path: 'summary/:orderIds',
|
||||||
component: CheckoutSummaryComponent,
|
component: CheckoutSummaryComponent,
|
||||||
title: 'Bestellübersicht',
|
title: 'Bestellbestätigung',
|
||||||
canDeactivate: [canDeactivateTabCleanup],
|
canDeactivate: [canDeactivateTabCleanup],
|
||||||
},
|
},
|
||||||
{ path: '', pathMatch: 'full', redirectTo: 'review' },
|
{ path: '', pathMatch: 'full', redirectTo: 'review' },
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Directive,
|
Directive,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
|
||||||
ViewChild,
|
ViewChild,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
AbstractControl,
|
AbstractControl,
|
||||||
@@ -58,9 +59,12 @@ import {
|
|||||||
CustomerCreateNavigation,
|
CustomerCreateNavigation,
|
||||||
CustomerSearchNavigation,
|
CustomerSearchNavigation,
|
||||||
} from '@shared/services/navigation';
|
} from '@shared/services/navigation';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { TabService } from '@isa/core/tabs';
|
||||||
|
import { getCustomerName } from '@isa/crm/data-access';
|
||||||
|
|
||||||
@Directive()
|
@Directive()
|
||||||
export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
|
export abstract class AbstractCreateCustomer implements OnDestroy {
|
||||||
protected modal = inject(UiModalService);
|
protected modal = inject(UiModalService);
|
||||||
|
|
||||||
protected activatedRoute = inject(ActivatedRoute);
|
protected activatedRoute = inject(ActivatedRoute);
|
||||||
@@ -80,6 +84,9 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
formData$ = this._formData.asObservable();
|
formData$ = this._formData.asObservable();
|
||||||
|
|
||||||
|
#tabService = inject(TabService);
|
||||||
|
#formData = toSignal(this.formData$);
|
||||||
|
|
||||||
get formData() {
|
get formData() {
|
||||||
return this._formData.getValue();
|
return this._formData.getValue();
|
||||||
}
|
}
|
||||||
@@ -104,12 +111,22 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
readonly customerCreateNavigation = inject(CustomerCreateNavigation);
|
readonly customerCreateNavigation = inject(CustomerCreateNavigation);
|
||||||
|
|
||||||
|
tabEffect = effect(() => {
|
||||||
|
const formData = this.#formData();
|
||||||
|
|
||||||
|
const name = getCustomerName(formData.name) || 'Kunden erfassen';
|
||||||
|
|
||||||
|
untracked(() =>
|
||||||
|
this.#tabService.patchTab(this.#tabService.activatedTabId(), {
|
||||||
|
subtitle: name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._initProcessId$();
|
this._initProcessId$();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {}
|
|
||||||
|
|
||||||
private _initProcessId$(): void {
|
private _initProcessId$(): void {
|
||||||
this.processId$ = this.activatedRoute.parent.parent.data.pipe(
|
this.processId$ = this.activatedRoute.parent.parent.data.pipe(
|
||||||
map((data) => +data.processId),
|
map((data) => +data.processId),
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
CrmTabMetadataService,
|
CrmTabMetadataService,
|
||||||
Customer,
|
Customer,
|
||||||
AssignedPayer,
|
AssignedPayer,
|
||||||
|
getCustomerName,
|
||||||
} from '@isa/crm/data-access';
|
} from '@isa/crm/data-access';
|
||||||
import {
|
import {
|
||||||
CustomerAdapter,
|
CustomerAdapter,
|
||||||
@@ -49,7 +50,7 @@ import {
|
|||||||
NavigateAfterRewardSelection,
|
NavigateAfterRewardSelection,
|
||||||
RewardSelectionPopUpService,
|
RewardSelectionPopUpService,
|
||||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
} 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';
|
import { ShippingAddressDTO as CrmShippingAddressDTO } from '@generated/swagger/crm-api';
|
||||||
|
|
||||||
interface SelectCustomerContext {
|
interface SelectCustomerContext {
|
||||||
@@ -112,9 +113,8 @@ export class CustomerDetailsViewMainComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkHasReturnUrl(): void {
|
checkHasReturnUrl(): void {
|
||||||
const hasContext = !!this._tabService.activatedTab()?.metadata?.[
|
const hasContext =
|
||||||
'select-customer'
|
!!this._tabService.activatedTab()?.metadata?.['select-customer'];
|
||||||
];
|
|
||||||
this.hasReturnUrl.set(hasContext);
|
this.hasReturnUrl.set(hasContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +302,8 @@ export class CustomerDetailsViewMainComponent
|
|||||||
this.hasKundenkarte$,
|
this.hasKundenkarte$,
|
||||||
]).pipe(map(([type, hasCard]) => type === 'webshop' || hasCard));
|
]).pipe(map(([type, hasCard]) => type === 'webshop' || hasCard));
|
||||||
|
|
||||||
|
customerSignal = toSignal(this._store.customer$);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
isBusy: false,
|
isBusy: false,
|
||||||
@@ -309,6 +311,8 @@ export class CustomerDetailsViewMainComponent
|
|||||||
shippingAddress: undefined,
|
shippingAddress: undefined,
|
||||||
payer: undefined,
|
payer: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useTabSubtitle(this.customerSignal, getCustomerName);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsBusy(isBusy: boolean) {
|
setIsBusy(isBusy: boolean) {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
inject,
|
||||||
|
computed,
|
||||||
|
} from '@angular/core';
|
||||||
import { CustomerSearchStore } from '../store';
|
import { CustomerSearchStore } from '../store';
|
||||||
import { Filter, FilterModule } from '@shared/components/filter';
|
import { Filter, FilterModule } from '@shared/components/filter';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
@@ -6,10 +11,15 @@ import { AsyncPipe } from '@angular/common';
|
|||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { IconComponent } from '@shared/components/icon';
|
import { IconComponent } from '@shared/components/icon';
|
||||||
import { combineLatest } from 'rxjs';
|
import { combineLatest } from 'rxjs';
|
||||||
import { CustomerSearchNavigation, CustomerCreateNavigation } from '@shared/services/navigation';
|
import {
|
||||||
|
CustomerSearchNavigation,
|
||||||
|
CustomerCreateNavigation,
|
||||||
|
} from '@shared/services/navigation';
|
||||||
import { CustomerFilterMainViewModule } from '../filter-main-view/filter-main-view.module';
|
import { CustomerFilterMainViewModule } from '../filter-main-view/filter-main-view.module';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import { CustomerInfoDTO } from '@generated/swagger/crm-api';
|
import { CustomerInfoDTO } from '@generated/swagger/crm-api';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { useQueryParamSubtitle } from '@isa/core/tabs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'page-customer-main-view',
|
selector: 'page-customer-main-view',
|
||||||
@@ -17,7 +27,13 @@ import { CustomerInfoDTO } from '@generated/swagger/crm-api';
|
|||||||
styleUrls: ['main-view.component.css'],
|
styleUrls: ['main-view.component.css'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
host: { class: 'page-customer-main-view' },
|
host: { class: 'page-customer-main-view' },
|
||||||
imports: [AsyncPipe, RouterLink, FilterModule, IconComponent, CustomerFilterMainViewModule],
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
RouterLink,
|
||||||
|
FilterModule,
|
||||||
|
IconComponent,
|
||||||
|
CustomerFilterMainViewModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class CustomerMainViewComponent {
|
export class CustomerMainViewComponent {
|
||||||
private _searchNavigation = inject(CustomerSearchNavigation);
|
private _searchNavigation = inject(CustomerSearchNavigation);
|
||||||
@@ -25,16 +41,28 @@ export class CustomerMainViewComponent {
|
|||||||
private _store = inject(CustomerSearchStore);
|
private _store = inject(CustomerSearchStore);
|
||||||
private _router = inject(Router);
|
private _router = inject(Router);
|
||||||
|
|
||||||
filterRoute$ = combineLatest([this._store.processId$, this._store.filter$]).pipe(
|
filterRoute$ = combineLatest([
|
||||||
|
this._store.processId$,
|
||||||
|
this._store.filter$,
|
||||||
|
]).pipe(
|
||||||
map(([processId, filter]) => {
|
map(([processId, filter]) => {
|
||||||
const route = this._searchNavigation.filterRoute({ processId, comingFrom: this._router.url?.split('?')[0] });
|
const route = this._searchNavigation.filterRoute({
|
||||||
|
processId,
|
||||||
|
comingFrom: this._router.url?.split('?')[0],
|
||||||
|
});
|
||||||
route.queryParams = { ...route.queryParams, ...filter?.getQueryParams() };
|
route.queryParams = { ...route.queryParams, ...filter?.getQueryParams() };
|
||||||
route.urlTree.queryParams = { ...route.urlTree.queryParams, ...filter?.getQueryParams() };
|
route.urlTree.queryParams = {
|
||||||
|
...route.urlTree.queryParams,
|
||||||
|
...filter?.getQueryParams(),
|
||||||
|
};
|
||||||
return route;
|
return route;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
createRoute$ = combineLatest(this._store.filter$, this._store.processId$).pipe(
|
createRoute$ = combineLatest(
|
||||||
|
this._store.filter$,
|
||||||
|
this._store.processId$,
|
||||||
|
).pipe(
|
||||||
map(([filter, processId]) => {
|
map(([filter, processId]) => {
|
||||||
const queryParams = filter?.getQueryParams();
|
const queryParams = filter?.getQueryParams();
|
||||||
|
|
||||||
@@ -52,7 +80,10 @@ export class CustomerMainViewComponent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return this._customerCreateNavigation.createCustomerRoute({ processId, customerInfo });
|
return this._customerCreateNavigation.createCustomerRoute({
|
||||||
|
processId,
|
||||||
|
customerInfo,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,6 +101,16 @@ export class CustomerMainViewComponent {
|
|||||||
|
|
||||||
message$ = this._store.message$;
|
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) {
|
search(filter: Filter) {
|
||||||
this._store.setFilter(filter);
|
this._store.setFilter(filter);
|
||||||
this._store.search({ resetScrollIndex: true, ignoreRestore: true });
|
this._store.search({ resetScrollIndex: true, ignoreRestore: true });
|
||||||
|
|||||||
@@ -6,17 +6,29 @@ import {
|
|||||||
AfterContentInit,
|
AfterContentInit,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
|
computed,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NavigationEnd, Router } from '@angular/router';
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
import { CustomerSearchStore } from '../store/customer-search.store';
|
import { CustomerSearchStore } from '../store/customer-search.store';
|
||||||
import { BehaviorSubject, Subject, Subscription, combineLatest, race } from 'rxjs';
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
Subject,
|
||||||
|
Subscription,
|
||||||
|
combineLatest,
|
||||||
|
race,
|
||||||
|
} from 'rxjs';
|
||||||
import { delay, filter, map, take, takeUntil } from 'rxjs/operators';
|
import { delay, filter, map, take, takeUntil } from 'rxjs/operators';
|
||||||
import { CustomerSearchNavigation, NavigationRoute } from '@shared/services/navigation';
|
import {
|
||||||
|
CustomerSearchNavigation,
|
||||||
|
NavigationRoute,
|
||||||
|
} from '@shared/services/navigation';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import { Filter } from '@shared/components/filter';
|
import { Filter } from '@shared/components/filter';
|
||||||
import { CustomerResultListComponent } from '../../components/customer-result-list/customer-result-list.component';
|
import { CustomerResultListComponent } from '../../components/customer-result-list/customer-result-list.component';
|
||||||
import { EnvironmentService } from '@core/environment';
|
import { EnvironmentService } from '@core/environment';
|
||||||
import { injectCancelSearch } from '@shared/services/cancel-subject';
|
import { injectCancelSearch } from '@shared/services/cancel-subject';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { useQueryParamSubtitle } from '@isa/core/tabs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'page-customer-results-main-view',
|
selector: 'page-customer-results-main-view',
|
||||||
@@ -25,7 +37,9 @@ import { injectCancelSearch } from '@shared/services/cancel-subject';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: false,
|
standalone: false,
|
||||||
})
|
})
|
||||||
export class CustomerResultsMainViewComponent implements OnInit, OnDestroy, AfterContentInit {
|
export class CustomerResultsMainViewComponent
|
||||||
|
implements OnInit, OnDestroy, AfterContentInit
|
||||||
|
{
|
||||||
private _store = inject(CustomerSearchStore);
|
private _store = inject(CustomerSearchStore);
|
||||||
private _router = inject(Router);
|
private _router = inject(Router);
|
||||||
private _navigation = inject(CustomerSearchNavigation);
|
private _navigation = inject(CustomerSearchNavigation);
|
||||||
@@ -39,8 +53,13 @@ export class CustomerResultsMainViewComponent implements OnInit, OnDestroy, Afte
|
|||||||
|
|
||||||
filterRoute$ = combineLatest([this.processId$, this.currentUrl$]).pipe(
|
filterRoute$ = combineLatest([this.processId$, this.currentUrl$]).pipe(
|
||||||
map(([processId, url]) => {
|
map(([processId, url]) => {
|
||||||
const route = this._navigation.filterRoute({ processId, comingFrom: url?.split('?')[0] });
|
const route = this._navigation.filterRoute({
|
||||||
const routeTree = this._router.createUrlTree(route.path, { queryParams: route.queryParams });
|
processId,
|
||||||
|
comingFrom: url?.split('?')[0],
|
||||||
|
});
|
||||||
|
const routeTree = this._router.createUrlTree(route.path, {
|
||||||
|
queryParams: route.queryParams,
|
||||||
|
});
|
||||||
const currentlyActive = this._router.isActive(routeTree, {
|
const currentlyActive = this._router.isActive(routeTree, {
|
||||||
fragment: 'ignored',
|
fragment: 'ignored',
|
||||||
matrixParams: 'ignored',
|
matrixParams: 'ignored',
|
||||||
@@ -76,7 +95,8 @@ export class CustomerResultsMainViewComponent implements OnInit, OnDestroy, Afte
|
|||||||
|
|
||||||
customers$ = this._store.customerList$;
|
customers$ = this._store.customerList$;
|
||||||
|
|
||||||
@ViewChild(CustomerResultListComponent, { static: true }) customerResultListComponent: CustomerResultListComponent;
|
@ViewChild(CustomerResultListComponent, { static: true })
|
||||||
|
customerResultListComponent: CustomerResultListComponent;
|
||||||
|
|
||||||
isTablet$ = this._environment.matchTablet$;
|
isTablet$ = this._environment.matchTablet$;
|
||||||
|
|
||||||
@@ -86,6 +106,16 @@ export class CustomerResultsMainViewComponent implements OnInit, OnDestroy, Afte
|
|||||||
|
|
||||||
private _onDestroy$ = new Subject<void>();
|
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 {
|
ngOnInit(): void {
|
||||||
this.routerEventsSubscription = this._router.events.subscribe((event) => {
|
this.routerEventsSubscription = this._router.events.subscribe((event) => {
|
||||||
if (event instanceof NavigationEnd) {
|
if (event instanceof NavigationEnd) {
|
||||||
@@ -104,9 +134,15 @@ export class CustomerResultsMainViewComponent implements OnInit, OnDestroy, Afte
|
|||||||
const scrollIndex = this._store.restoreScrollIndex();
|
const scrollIndex = this._store.restoreScrollIndex();
|
||||||
|
|
||||||
if (typeof scrollIndex === 'number') {
|
if (typeof scrollIndex === 'number') {
|
||||||
const hasCustomerList$ = this._store.customerList$.pipe(filter((customers) => customers?.length > 0));
|
const hasCustomerList$ = this._store.customerList$.pipe(
|
||||||
|
filter((customers) => customers?.length > 0),
|
||||||
|
);
|
||||||
|
|
||||||
race(hasCustomerList$, this._store.customerListRestored$, this._store.customerListResponse$)
|
race(
|
||||||
|
hasCustomerList$,
|
||||||
|
this._store.customerListRestored$,
|
||||||
|
this._store.customerListResponse$,
|
||||||
|
)
|
||||||
.pipe(takeUntil(this._onDestroy$), delay(100), take(1))
|
.pipe(takeUntil(this._onDestroy$), delay(100), take(1))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.customerResultListComponent.scrollToIndex(Number(scrollIndex));
|
this.customerResultListComponent.scrollToIndex(Number(scrollIndex));
|
||||||
|
|||||||
@@ -43,19 +43,19 @@ export const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'search',
|
path: 'search',
|
||||||
component: CustomerMainViewComponent,
|
component: CustomerMainViewComponent,
|
||||||
title: 'Kundensuche',
|
title: 'Kunden',
|
||||||
data: { side: 'main', breadcrumb: 'main' },
|
data: { side: 'main', breadcrumb: 'main' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'search/list',
|
path: 'search/list',
|
||||||
component: CustomerResultsMainViewComponent,
|
component: CustomerResultsMainViewComponent,
|
||||||
title: 'Kundensuche - Trefferliste',
|
title: 'Kundensuche',
|
||||||
data: { breadcrumb: 'search' },
|
data: { breadcrumb: 'search' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'search/filter',
|
path: 'search/filter',
|
||||||
component: CustomerFilterMainViewComponent,
|
component: CustomerFilterMainViewComponent,
|
||||||
title: 'Kundensuche - Filter',
|
title: 'Kundensuche',
|
||||||
data: { side: 'results', breadcrumb: 'filter' },
|
data: { side: 'results', breadcrumb: 'filter' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -161,9 +161,13 @@ export const routes: Routes = [
|
|||||||
component: CreateCustomerComponent,
|
component: CreateCustomerComponent,
|
||||||
canActivate: [CustomerCreateGuard],
|
canActivate: [CustomerCreateGuard],
|
||||||
canActivateChild: [CustomerCreateGuard],
|
canActivateChild: [CustomerCreateGuard],
|
||||||
title: 'Kundendaten erfassen',
|
title: 'Kunden erfassen',
|
||||||
children: [
|
children: [
|
||||||
{ path: 'create', component: CreateStoreCustomerComponent },
|
{
|
||||||
|
path: 'create',
|
||||||
|
component: CreateStoreCustomerComponent,
|
||||||
|
title: 'Kunden',
|
||||||
|
},
|
||||||
{ path: 'create/store', component: CreateStoreCustomerComponent },
|
{ path: 'create/store', component: CreateStoreCustomerComponent },
|
||||||
{ path: 'create/webshop', component: CreateWebshopCustomerComponent },
|
{ path: 'create/webshop', component: CreateWebshopCustomerComponent },
|
||||||
{ path: 'create/b2b', component: CreateB2BCustomerComponent },
|
{ path: 'create/b2b', component: CreateB2BCustomerComponent },
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from './lib/isa-title.strategy';
|
|
||||||
export * from './lib/use-page-title.function';
|
|
||||||
export * from './lib/title-management.types';
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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'));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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(),
|
|
||||||
);
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -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:**
|
**Parameters:**
|
||||||
- `id: number` - Tab ID to query
|
- `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
|
**Returns:** Current location, or null if none or invalid
|
||||||
|
|
||||||
**Side Effects:**
|
**Side Effects:**
|
||||||
- Automatically corrects invalid history indices
|
- Automatically corrects invalid history indices (when `config.enableIndexValidation` is true)
|
||||||
- Logs warnings if index correction occurs (when enabled)
|
- Logs warnings if index correction occurs
|
||||||
|
- Triggers storage autosave when corrections are made
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```typescript
|
```typescript
|
||||||
const currentLoc = this.#tabService.getCurrentLocation(42);
|
const currentLoc = this.#tabService.getCurrentLocationWithValidation(42);
|
||||||
if (currentLoc) {
|
if (currentLoc) {
|
||||||
console.log(`Current page: ${currentLoc.title}`);
|
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`
|
#### `getTabHelper(tabId: number, entities: Record<number, Tab>): Tab | undefined`
|
||||||
|
|
||||||
Retrieves a tab from entity map.
|
Retrieves a tab from entity map.
|
||||||
@@ -1502,7 +1549,7 @@ withStorage('tabs', UserStorageProvider, { autosave: true })
|
|||||||
|
|
||||||
The system maintains history index integrity through:
|
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
|
2. **Validation on navigation** - Back/forward check bounds
|
||||||
3. **Correction on errors** - Invalid indices auto-corrected
|
3. **Correction on errors** - Invalid indices auto-corrected
|
||||||
4. **Logging** - Optional warnings for debugging
|
4. **Logging** - Optional warnings for debugging
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
export * from './lib/navigate-back-button.component';
|
export * from './lib/components';
|
||||||
export * from './lib/tab.injector';
|
export * from './lib/guards';
|
||||||
export * from './lib/tab.resolver-fn';
|
export * from './lib/resolvers';
|
||||||
export * from './lib/schemas';
|
export * from './lib/provider';
|
||||||
export * from './lib/tab';
|
|
||||||
export * from './lib/tab-navigation.service';
|
|
||||||
export * from './lib/tab-navigation.constants';
|
|
||||||
export * from './lib/tab-config';
|
|
||||||
export * from './lib/helpers';
|
export * from './lib/helpers';
|
||||||
export * from './lib/has-tab-id.guard';
|
export * from './lib/tab.service';
|
||||||
export * from './lib/tab-cleanup.guard';
|
export * from './lib/tab-config';
|
||||||
export * from './lib/deactivate-tab.guard';
|
export * from './lib/schemas';
|
||||||
|
export * from './lib/tab.injector';
|
||||||
|
|||||||
1
libs/core/tabs/src/lib/components/index.ts
Normal file
1
libs/core/tabs/src/lib/components/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './navigate-back-button.component';
|
||||||
@@ -2,7 +2,7 @@ import { Component, inject, computed, input } from '@angular/core';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { isaActionChevronLeft } from '@isa/icons';
|
import { isaActionChevronLeft } from '@isa/icons';
|
||||||
import { TabService } from './tab';
|
import { TabService } from '../tab.service';
|
||||||
import { ButtonComponent } from '@isa/ui/buttons';
|
import { ButtonComponent } from '@isa/ui/buttons';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
/**
|
import { inject, InjectionToken } from '@angular/core';
|
||||||
* @fileoverview Constants for tab navigation behavior and URL filtering.
|
import { Config } from '@core/config';
|
||||||
*
|
import { z } from 'zod';
|
||||||
* This module provides configuration constants that control how URLs are
|
|
||||||
* handled in the tab navigation history system.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL patterns that should not be added to tab navigation history.
|
* 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 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'));
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { CanActivateFn } from '@angular/router';
|
import { CanActivateFn } from '@angular/router';
|
||||||
import { TabService } from './tab';
|
import { TabService } from '../tab.service';
|
||||||
import { logger } from '@isa/core/logging';
|
import { logger } from '@isa/core/logging';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
3
libs/core/tabs/src/lib/guards/index.ts
Normal file
3
libs/core/tabs/src/lib/guards/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './deactivate-tab.guard';
|
||||||
|
export * from './has-tab-id.guard';
|
||||||
|
export * from './tab-cleanup.guard';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { CanDeactivateFn, Router } from '@angular/router';
|
import { CanDeactivateFn, Router } from '@angular/router';
|
||||||
import { TabService } from './tab';
|
import { TabService } from '../tab.service';
|
||||||
import { logger } from '@isa/core/logging';
|
import { logger } from '@isa/core/logging';
|
||||||
import {
|
import {
|
||||||
CheckoutMetadataService,
|
CheckoutMetadataService,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
getNextTabNameHelper,
|
getNextTabNameHelper,
|
||||||
formatCustomerTabNameHelper,
|
formatCustomerTabNameHelper,
|
||||||
checkCartHasItemsHelper,
|
checkCartHasItemsHelper,
|
||||||
} from './helpers';
|
} from '../helpers';
|
||||||
import { DomainCheckoutService } from '@domain/checkout';
|
import { DomainCheckoutService } from '@domain/checkout';
|
||||||
import { CrmTabMetadataService } from '@isa/crm/data-access';
|
import { CrmTabMetadataService } from '@isa/crm/data-access';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
* different trade-offs between memory usage and history preservation.
|
* different trade-offs between memory usage and history preservation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { TabLocation, TabLocationHistory } from './schemas';
|
import { TabLocation, TabLocationHistory } from '../schemas';
|
||||||
import { TabConfig } from './tab-config';
|
import { TabConfig } from '../tab-config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of a history pruning operation.
|
* Result of a history pruning operation.
|
||||||
@@ -39,7 +39,6 @@ export interface HistoryPruningResult {
|
|||||||
* navigation functionality. All methods are static and stateless.
|
* navigation functionality. All methods are static and stateless.
|
||||||
*/
|
*/
|
||||||
export class TabHistoryPruner {
|
export class TabHistoryPruner {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prunes history based on the configured strategy.
|
* Prunes history based on the configured strategy.
|
||||||
*
|
*
|
||||||
@@ -66,7 +65,7 @@ export class TabHistoryPruner {
|
|||||||
static pruneHistory(
|
static pruneHistory(
|
||||||
locationHistory: TabLocationHistory,
|
locationHistory: TabLocationHistory,
|
||||||
config: TabConfig,
|
config: TabConfig,
|
||||||
tabMetadata?: { maxHistorySize?: number; maxForwardHistory?: number }
|
tabMetadata?: { maxHistorySize?: number; maxForwardHistory?: number },
|
||||||
): HistoryPruningResult {
|
): HistoryPruningResult {
|
||||||
const maxSize = tabMetadata?.maxHistorySize ?? config.maxHistorySize;
|
const maxSize = tabMetadata?.maxHistorySize ?? config.maxHistorySize;
|
||||||
const { locations, current } = locationHistory;
|
const { locations, current } = locationHistory;
|
||||||
@@ -76,7 +75,7 @@ export class TabHistoryPruner {
|
|||||||
locations: [...locations],
|
locations: [...locations],
|
||||||
newCurrent: current,
|
newCurrent: current,
|
||||||
entriesRemoved: 0,
|
entriesRemoved: 0,
|
||||||
strategy: 'no-pruning'
|
strategy: 'no-pruning',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +114,7 @@ export class TabHistoryPruner {
|
|||||||
private static pruneOldestFirst(
|
private static pruneOldestFirst(
|
||||||
locations: TabLocation[],
|
locations: TabLocation[],
|
||||||
current: number,
|
current: number,
|
||||||
maxSize: number
|
maxSize: number,
|
||||||
): HistoryPruningResult {
|
): HistoryPruningResult {
|
||||||
const removeCount = locations.length - maxSize;
|
const removeCount = locations.length - maxSize;
|
||||||
const prunedLocations = locations.slice(removeCount);
|
const prunedLocations = locations.slice(removeCount);
|
||||||
@@ -125,7 +124,7 @@ export class TabHistoryPruner {
|
|||||||
locations: prunedLocations,
|
locations: prunedLocations,
|
||||||
newCurrent,
|
newCurrent,
|
||||||
entriesRemoved: removeCount,
|
entriesRemoved: removeCount,
|
||||||
strategy: 'oldest-first'
|
strategy: 'oldest-first',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +150,7 @@ export class TabHistoryPruner {
|
|||||||
private static pruneBalanced(
|
private static pruneBalanced(
|
||||||
locations: TabLocation[],
|
locations: TabLocation[],
|
||||||
current: number,
|
current: number,
|
||||||
maxSize: number
|
maxSize: number,
|
||||||
): HistoryPruningResult {
|
): HistoryPruningResult {
|
||||||
// Preserve 70% of entries before current, 30% after
|
// Preserve 70% of entries before current, 30% after
|
||||||
const backwardRatio = 0.7;
|
const backwardRatio = 0.7;
|
||||||
@@ -169,7 +168,7 @@ export class TabHistoryPruner {
|
|||||||
locations: prunedLocations,
|
locations: prunedLocations,
|
||||||
newCurrent,
|
newCurrent,
|
||||||
entriesRemoved,
|
entriesRemoved,
|
||||||
strategy: 'balanced'
|
strategy: 'balanced',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +202,7 @@ export class TabHistoryPruner {
|
|||||||
locations: TabLocation[],
|
locations: TabLocation[],
|
||||||
current: number,
|
current: number,
|
||||||
maxSize: number,
|
maxSize: number,
|
||||||
config: TabConfig
|
config: TabConfig,
|
||||||
): HistoryPruningResult {
|
): HistoryPruningResult {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const oneHour = 60 * 60 * 1000;
|
const oneHour = 60 * 60 * 1000;
|
||||||
@@ -220,20 +219,20 @@ export class TabHistoryPruner {
|
|||||||
else if (age > oneHour) recencyScore = 60;
|
else if (age > oneHour) recencyScore = 60;
|
||||||
|
|
||||||
// Locations near current position get higher scores
|
// Locations near current position get higher scores
|
||||||
const proximityScore = Math.max(0, 100 - (distanceFromCurrent * 10));
|
const proximityScore = Math.max(0, 100 - distanceFromCurrent * 10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
location,
|
location,
|
||||||
index,
|
index,
|
||||||
score: recencyScore + proximityScore,
|
score: recencyScore + proximityScore,
|
||||||
isCurrent: index === current
|
isCurrent: index === current,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always keep current location and sort others by score
|
// Always keep current location and sort others by score
|
||||||
const currentItem = scoredLocations.find(item => item.isCurrent);
|
const currentItem = scoredLocations.find((item) => item.isCurrent);
|
||||||
const otherItems = scoredLocations
|
const otherItems = scoredLocations
|
||||||
.filter(item => !item.isCurrent)
|
.filter((item) => !item.isCurrent)
|
||||||
.sort((a, b) => b.score - a.score);
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
// Take top scoring items
|
// Take top scoring items
|
||||||
@@ -247,15 +246,18 @@ export class TabHistoryPruner {
|
|||||||
// Sort by original index to maintain order
|
// Sort by original index to maintain order
|
||||||
keptItems.sort((a, b) => a.index - b.index);
|
keptItems.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
const prunedLocations = keptItems.map(item => item.location);
|
const prunedLocations = keptItems.map((item) => item.location);
|
||||||
const newCurrent = keptItems.findIndex(item => item.isCurrent);
|
const newCurrent = keptItems.findIndex((item) => item.isCurrent);
|
||||||
const entriesRemoved = locations.length - prunedLocations.length;
|
const entriesRemoved = locations.length - prunedLocations.length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locations: prunedLocations,
|
locations: prunedLocations,
|
||||||
newCurrent: newCurrent === -1 ? Math.max(0, prunedLocations.length - 1) : newCurrent,
|
newCurrent:
|
||||||
|
newCurrent === -1
|
||||||
|
? Math.max(0, prunedLocations.length - 1)
|
||||||
|
: newCurrent,
|
||||||
entriesRemoved,
|
entriesRemoved,
|
||||||
strategy: 'smart'
|
strategy: 'smart',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +283,7 @@ export class TabHistoryPruner {
|
|||||||
static pruneForwardHistory(
|
static pruneForwardHistory(
|
||||||
locations: TabLocation[],
|
locations: TabLocation[],
|
||||||
current: number,
|
current: number,
|
||||||
maxForwardHistory: number
|
maxForwardHistory: number,
|
||||||
): { locations: TabLocation[]; newCurrent: number } {
|
): { locations: TabLocation[]; newCurrent: number } {
|
||||||
if (current < 0 || current >= locations.length) {
|
if (current < 0 || current >= locations.length) {
|
||||||
return { locations: [...locations], newCurrent: current };
|
return { locations: [...locations], newCurrent: current };
|
||||||
@@ -295,7 +297,7 @@ export class TabHistoryPruner {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
locations: prunedLocations,
|
locations: prunedLocations,
|
||||||
newCurrent: current // Current position unchanged
|
newCurrent: current, // Current position unchanged
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +326,7 @@ export class TabHistoryPruner {
|
|||||||
*/
|
*/
|
||||||
static validateLocationIndex(
|
static validateLocationIndex(
|
||||||
locations: TabLocation[],
|
locations: TabLocation[],
|
||||||
current: number
|
current: number,
|
||||||
): { index: number; wasInvalid: boolean } {
|
): { index: number; wasInvalid: boolean } {
|
||||||
if (locations.length === 0) {
|
if (locations.length === 0) {
|
||||||
return { index: -1, wasInvalid: current !== -1 };
|
return { index: -1, wasInvalid: current !== -1 };
|
||||||
@@ -332,7 +334,10 @@ export class TabHistoryPruner {
|
|||||||
|
|
||||||
if (current < -1 || current >= locations.length) {
|
if (current < -1 || current >= locations.length) {
|
||||||
// Invalid index, correct to last valid position
|
// Invalid index, correct to last valid position
|
||||||
const correctedIndex = Math.max(-1, Math.min(locations.length - 1, current));
|
const correctedIndex = Math.max(
|
||||||
|
-1,
|
||||||
|
Math.min(locations.length - 1, current),
|
||||||
|
);
|
||||||
return { index: correctedIndex, wasInvalid: true };
|
return { index: correctedIndex, wasInvalid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject, Injector } from '@angular/core';
|
||||||
import { NavigationEnd, Router } from '@angular/router';
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
import { filter } from 'rxjs/operators';
|
import { filter } from 'rxjs/operators';
|
||||||
import { TabService } from './tab';
|
import { TabService } from '../tab.service';
|
||||||
import { TabLocation } from './schemas';
|
import { TabLocation } from '../schemas';
|
||||||
import { Title } from '@angular/platform-browser';
|
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.
|
* 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,
|
* The service is designed to work seamlessly with the tab history pruning system,
|
||||||
* providing fallback mechanisms when navigation history has been pruned.
|
* providing fallback mechanisms when navigation history has been pruned.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable()
|
||||||
export class TabNavigationService {
|
export class TabNavigationService {
|
||||||
#router = inject(Router);
|
#router = inject(Router);
|
||||||
#tabService = inject(TabService);
|
#injector = inject(Injector);
|
||||||
#title = inject(Title);
|
#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() {
|
init() {
|
||||||
this.#router.events
|
this.#router.events
|
||||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||||
@@ -51,14 +58,14 @@ export class TabNavigationService {
|
|||||||
const location = this.#createTabLocation(event.url);
|
const location = this.#createTabLocation(event.url);
|
||||||
|
|
||||||
// Check if this location already exists in history (browser back/forward)
|
// Check if this location already exists in history (browser back/forward)
|
||||||
const currentTab = this.#tabService.entityMap()[activeTabId];
|
const currentTab = this.#getTabService().entityMap()[activeTabId];
|
||||||
if (
|
if (
|
||||||
currentTab &&
|
currentTab &&
|
||||||
this.#isLocationInHistory(currentTab.location.locations, location)
|
this.#isLocationInHistory(currentTab.location.locations, location)
|
||||||
) {
|
) {
|
||||||
this.#handleBrowserNavigation(activeTabId, location);
|
this.#handleBrowserNavigation(activeTabId, location);
|
||||||
} else {
|
} 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
|
* @returns true if the URL should be skipped, false otherwise
|
||||||
*/
|
*/
|
||||||
#shouldSkipHistory(url: string): boolean {
|
#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 {
|
#getActiveTabId(url: string): number | null {
|
||||||
@@ -91,7 +100,7 @@ export class TabNavigationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If no ID in URL, use currently activated tab
|
// If no ID in URL, use currently activated tab
|
||||||
return this.#tabService.activatedTabId();
|
return this.#getTabService().activatedTabId();
|
||||||
}
|
}
|
||||||
|
|
||||||
#createTabLocation(url: string): TabLocation {
|
#createTabLocation(url: string): TabLocation {
|
||||||
@@ -131,7 +140,7 @@ export class TabNavigationService {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
#handleBrowserNavigation(tabId: number, location: TabLocation) {
|
#handleBrowserNavigation(tabId: number, location: TabLocation) {
|
||||||
const currentTab = this.#tabService.entityMap()[tabId];
|
const currentTab = this.#getTabService().entityMap()[tabId];
|
||||||
if (!currentTab) return;
|
if (!currentTab) return;
|
||||||
|
|
||||||
const locationIndex = currentTab.location.locations.findIndex(
|
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 location not found in history (possibly pruned), navigate to new location
|
||||||
if (locationIndex === -1) {
|
if (locationIndex === -1) {
|
||||||
this.#tabService.navigateToLocation(tabId, location);
|
this.#getTabService().navigateToLocation(tabId, location);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +163,7 @@ export class TabNavigationService {
|
|||||||
const maxAttempts = Math.abs(steps) + 5; // Prevent infinite loops
|
const maxAttempts = Math.abs(steps) + 5; // Prevent infinite loops
|
||||||
|
|
||||||
while (steps > 0 && attempts < maxAttempts) {
|
while (steps > 0 && attempts < maxAttempts) {
|
||||||
const result = this.#tabService.navigateBack(tabId);
|
const result = this.#getTabService().navigateBack(tabId);
|
||||||
if (!result) break;
|
if (!result) break;
|
||||||
steps--;
|
steps--;
|
||||||
attempts++;
|
attempts++;
|
||||||
@@ -162,7 +171,7 @@ export class TabNavigationService {
|
|||||||
|
|
||||||
// If we couldn't reach the target, fallback to direct navigation
|
// If we couldn't reach the target, fallback to direct navigation
|
||||||
if (steps > 0) {
|
if (steps > 0) {
|
||||||
this.#tabService.navigateToLocation(tabId, location);
|
this.#getTabService().navigateToLocation(tabId, location);
|
||||||
}
|
}
|
||||||
} else if (locationIndex > currentIndex) {
|
} else if (locationIndex > currentIndex) {
|
||||||
// Navigate forward
|
// Navigate forward
|
||||||
@@ -171,7 +180,7 @@ export class TabNavigationService {
|
|||||||
const maxAttempts = Math.abs(steps) + 5; // Prevent infinite loops
|
const maxAttempts = Math.abs(steps) + 5; // Prevent infinite loops
|
||||||
|
|
||||||
while (steps > 0 && attempts < maxAttempts) {
|
while (steps > 0 && attempts < maxAttempts) {
|
||||||
const result = this.#tabService.navigateForward(tabId);
|
const result = this.#getTabService().navigateForward(tabId);
|
||||||
if (!result) break;
|
if (!result) break;
|
||||||
steps--;
|
steps--;
|
||||||
attempts++;
|
attempts++;
|
||||||
@@ -179,7 +188,7 @@ export class TabNavigationService {
|
|||||||
|
|
||||||
// If we couldn't reach the target, fallback to direct navigation
|
// If we couldn't reach the target, fallback to direct navigation
|
||||||
if (steps > 0) {
|
if (steps > 0) {
|
||||||
this.#tabService.navigateToLocation(tabId, location);
|
this.#getTabService().navigateToLocation(tabId, location);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If locationIndex === currentIndex, we're already at the right position
|
// If locationIndex === currentIndex, we're already at the right position
|
||||||
@@ -210,7 +219,7 @@ export class TabNavigationService {
|
|||||||
|
|
||||||
if (activeTabId) {
|
if (activeTabId) {
|
||||||
const location = this.#createTabLocation(url);
|
const location = this.#createTabLocation(url);
|
||||||
this.#tabService.navigateToLocation(activeTabId, location);
|
this.#getTabService().navigateToLocation(activeTabId, location);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import { Injectable, inject, Injector } from '@angular/core';
|
|||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
|
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
|
||||||
import { TabService } from '@isa/core/tabs';
|
import { TabService } from '@isa/core/tabs';
|
||||||
import { TITLE_PREFIX } from './title-prefix';
|
import { TITLE_PREFIX } from '../constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom TitleStrategy for the ISA application that:
|
* Custom TitleStrategy for the ISA application that:
|
||||||
@@ -41,8 +41,8 @@ import { TITLE_PREFIX } from './title-prefix';
|
|||||||
* ];
|
* ];
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable()
|
||||||
export class IsaTitleStrategy extends TitleStrategy {
|
export class TabTitleStrategy extends TitleStrategy {
|
||||||
readonly #title = inject(Title);
|
readonly #title = inject(Title);
|
||||||
readonly #injector = inject(Injector);
|
readonly #injector = inject(Injector);
|
||||||
readonly #titlePrefix = inject(TITLE_PREFIX);
|
readonly #titlePrefix = inject(TITLE_PREFIX);
|
||||||
19
libs/core/tabs/src/lib/provider.ts
Normal file
19
libs/core/tabs/src/lib/provider.ts
Normal 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();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
1
libs/core/tabs/src/lib/resolvers/index.ts
Normal file
1
libs/core/tabs/src/lib/resolvers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './tab.resolver-fn';
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ResolveFn } from '@angular/router';
|
import { ResolveFn } from '@angular/router';
|
||||||
import { TabService } from './tab';
|
import { TabService } from '../tab.service';
|
||||||
import { Tab } from './schemas';
|
import { Tab } from '../schemas';
|
||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { logger } from '@isa/core/logging';
|
import { logger } from '@isa/core/logging';
|
||||||
import { getNextTabNameHelper } from './helpers';
|
import { getNextTabNameHelper } from '../helpers';
|
||||||
|
|
||||||
export const tabResolverFn: ResolveFn<Tab> = (route) => {
|
export const tabResolverFn: ResolveFn<Tab> = (route) => {
|
||||||
const log = logger(() => ({
|
const log = logger(() => ({
|
||||||
@@ -89,6 +89,24 @@ export const TabTagsSchema = z.array(z.string()).default([]);
|
|||||||
/** TypeScript type for tab tags */
|
/** TypeScript type for tab tags */
|
||||||
export type TabTags = z.infer<typeof TabTagsSchema>;
|
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).
|
* Base schema for tab validation (runtime validation only).
|
||||||
*
|
*
|
||||||
@@ -112,6 +130,12 @@ export const TabSchema = z.object({
|
|||||||
location: TabLocationHistorySchema,
|
location: TabLocationHistorySchema,
|
||||||
/** Array of tags for organization */
|
/** Array of tags for organization */
|
||||||
tags: TabTagsSchema,
|
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;
|
location: TabLocationHistory;
|
||||||
/** Organization tags */
|
/** Organization tags */
|
||||||
tags: string[];
|
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;
|
location: TabLocationHistory;
|
||||||
/** Organization tags */
|
/** Organization tags */
|
||||||
tags: string[];
|
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,
|
location: TabLocationHistorySchema,
|
||||||
/** Organization tags */
|
/** Organization tags */
|
||||||
tags: TabTagsSchema,
|
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();
|
.strict();
|
||||||
|
|
||||||
@@ -213,6 +255,12 @@ export const AddTabSchema = z.object({
|
|||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
/** Optional activation timestamp */
|
/** Optional activation timestamp */
|
||||||
activatedAt: z.number().optional(),
|
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 */
|
/** TypeScript type for adding tabs */
|
||||||
@@ -241,6 +289,12 @@ export const TabUpdateSchema = z
|
|||||||
location: TabLocationHistorySchema.optional(),
|
location: TabLocationHistorySchema.optional(),
|
||||||
/** Updated tags array */
|
/** Updated tags array */
|
||||||
tags: z.array(z.string()).optional(),
|
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();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Params, QueryParamsHandling } from '@angular/router';
|
||||||
import { Config } from '@isa/core/config';
|
import { Config } from '@isa/core/config';
|
||||||
|
import { logger } from '@isa/core/logging';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { TabService } from './tab';
|
import { TabService } from './tab.service';
|
||||||
|
|
||||||
const ReservedProcessIdsSchema = z.object({
|
const ReservedProcessIdsSchema = z.object({
|
||||||
goodsOut: z.number(),
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ import {
|
|||||||
TabLocationHistory,
|
TabLocationHistory,
|
||||||
} from './schemas';
|
} from './schemas';
|
||||||
import { TAB_CONFIG } from './tab-config';
|
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 { computed, inject } from '@angular/core';
|
||||||
import { withDevtools } from '@angular-architects/ngrx-toolkit';
|
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 { withStorage, UserStorageProvider } from '@isa/core/storage';
|
||||||
import { logger } from '@isa/core/logging';
|
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
|
* **Side Effect Warning:** If index validation is enabled (`config.enableIndexValidation`)
|
||||||
* and an invalid index is detected, it will automatically correct the index
|
* and an invalid index is detected, this method will automatically correct the index
|
||||||
* in the store, triggering state updates and storage autosave.
|
* 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
|
* @param id - The tab ID
|
||||||
* @returns The current location or null if tab doesn't exist or history is empty
|
* @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];
|
const currentTab = store.entityMap()[id];
|
||||||
if (!currentTab) return null;
|
if (!currentTab) return null;
|
||||||
|
|
||||||
@@ -427,7 +428,7 @@ export const TabService = signalStore(
|
|||||||
|
|
||||||
if (wasInvalid && store._config.enableIndexValidation) {
|
if (wasInvalid && store._config.enableIndexValidation) {
|
||||||
store._logger.warn(
|
store._logger.warn(
|
||||||
'Invalid location index corrected in getCurrentLocation',
|
'Invalid location index corrected in getCurrentLocationWithValidation',
|
||||||
() => ({
|
() => ({
|
||||||
tabId: id,
|
tabId: id,
|
||||||
invalidIndex: currentLocation.current,
|
invalidIndex: currentLocation.current,
|
||||||
@@ -79,6 +79,12 @@ export class ShellNavigationItemComponent {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate SVG structure before bypassing security
|
||||||
|
const trimmed = icon.trim();
|
||||||
|
if (!trimmed.startsWith('<svg') || !trimmed.includes('</svg>')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
return this.#sanitizer.bypassSecurityTrustHtml(icon);
|
return this.#sanitizer.bypassSecurityTrustHtml(icon);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export const navigations: Array<NavigationGroup | NavigationItem> = [
|
|||||||
() => {
|
() => {
|
||||||
const routeSignal = injectLabeledTabRoute('Remission', [
|
const routeSignal = injectLabeledTabRoute('Remission', [
|
||||||
'remission',
|
'remission',
|
||||||
|
'mandatory',
|
||||||
]);
|
]);
|
||||||
const remissionStore = inject(RemissionStore);
|
const remissionStore = inject(RemissionStore);
|
||||||
return computed(() => ({
|
return computed(() => ({
|
||||||
|
|||||||
@@ -79,3 +79,76 @@ a.compact button {
|
|||||||
a.compact .close-icon {
|
a.compact .close-icon {
|
||||||
font-size: 0.75rem;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,29 +1,71 @@
|
|||||||
@let _route = route();
|
@let _route = route();
|
||||||
|
@let _tab = tab();
|
||||||
|
@let _compact = compact();
|
||||||
|
@let _hasIcon = _tab.icon || showIndicator();
|
||||||
|
|
||||||
<a
|
<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="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 ?? '/'"
|
[routerLink]="_route?.urlTree ?? '/'"
|
||||||
[title]="_route?.title ?? ''"
|
[title]="_route?.title ?? ''"
|
||||||
data-what="tab-link"
|
data-what="tab-link"
|
||||||
[attr.data-which]="tab().id"
|
[attr.data-which]="_tab.id"
|
||||||
[attr.aria-current]="active() ? 'page' : null"
|
[attr.aria-current]="active() ? 'page' : null"
|
||||||
>
|
>
|
||||||
|
<!-- 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- 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="grow min-w-0">
|
||||||
<div class="isa-text-caption-bold truncate w-full">
|
<div class="isa-text-caption-bold truncate w-full">{{ _tab.name }}</div>
|
||||||
{{ tab().name }}
|
<div class="subtitle" [class.collapsed]="_compact">
|
||||||
</div>
|
<span class="isa-text-caption-regular w-full">{{ _tab.subtitle }}</span>
|
||||||
<div class="subtitle" [class.collapsed]="compact()">
|
|
||||||
<span class="isa-text-caption-regular w-full">
|
|
||||||
{{ tab().subtitle }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Close button -->
|
||||||
<button
|
<button
|
||||||
(click)="close($event)"
|
(click)="close($event)"
|
||||||
class="grow-0"
|
class="grow-0"
|
||||||
data-what="button"
|
data-what="button"
|
||||||
data-which="close-tab"
|
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>
|
<ng-icon name="isaActionClose" class="close-icon"></ng-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
computed,
|
computed,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
import { logger } from '@isa/core/logging';
|
import { logger } from '@isa/core/logging';
|
||||||
import { Tab } from '@isa/core/tabs';
|
import { Tab } from '@isa/core/tabs';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
@@ -42,6 +43,7 @@ export class ShellTabItemComponent {
|
|||||||
#logger = logger({ component: 'ShellTabItemComponent' });
|
#logger = logger({ component: 'ShellTabItemComponent' });
|
||||||
#router = inject(Router);
|
#router = inject(Router);
|
||||||
#tabService = inject(TabService);
|
#tabService = inject(TabService);
|
||||||
|
#sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
/** The tab entity to display. */
|
/** The tab entity to display. */
|
||||||
tab = input.required<Tab>();
|
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. */
|
/** The route for this tab, parsed as a UrlTree for proper auxiliary route handling. */
|
||||||
route = computed(() => {
|
route = computed(() => {
|
||||||
const tab = this.tab();
|
const tab = this.tab();
|
||||||
const location = this.#tabService.getCurrentLocation(tab.id);
|
const location = this.#tabService.getCurrentLocationWithValidation(tab.id);
|
||||||
if (!location?.url) {
|
if (!location?.url) {
|
||||||
return null;
|
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.
|
* Closes this tab and navigates to the previously active tab.
|
||||||
* If no previous tab exists, navigates to the root route.
|
* If no previous tab exists, navigates to the root route.
|
||||||
@@ -84,7 +137,7 @@ export class ShellTabItemComponent {
|
|||||||
const previousTab = this.#tabService.removeTab(tabId);
|
const previousTab = this.#tabService.removeTab(tabId);
|
||||||
|
|
||||||
if (previousTab) {
|
if (previousTab) {
|
||||||
const location = this.#tabService.getCurrentLocation(previousTab.id);
|
const location = this.#tabService.getCurrentLocationWithValidation(previousTab.id);
|
||||||
if (location?.url) {
|
if (location?.url) {
|
||||||
await this.#router.navigateByUrl(location.url);
|
await this.#router.navigateByUrl(location.url);
|
||||||
return;
|
return;
|
||||||
@@ -96,4 +149,38 @@ export class ShellTabItemComponent {
|
|||||||
this.#logger.error('Failed to close tab', error as Error, () => ({ tabId }));
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,6 @@
|
|||||||
"@isa/common/data-access": ["libs/common/data-access/src/index.ts"],
|
"@isa/common/data-access": ["libs/common/data-access/src/index.ts"],
|
||||||
"@isa/common/decorators": ["libs/common/decorators/src/index.ts"],
|
"@isa/common/decorators": ["libs/common/decorators/src/index.ts"],
|
||||||
"@isa/common/print": ["libs/common/print/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/auth": ["libs/core/auth/src/index.ts"],
|
||||||
"@isa/core/config": ["libs/core/config/src/index.ts"],
|
"@isa/core/config": ["libs/core/config/src/index.ts"],
|
||||||
"@isa/core/connectivity": ["libs/core/connectivity/src/index.ts"],
|
"@isa/core/connectivity": ["libs/core/connectivity/src/index.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user