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 { IsaTitleStrategy } from '@isa/common/title-management';
|
||||
import {
|
||||
HTTP_INTERCEPTORS,
|
||||
HttpInterceptorFn,
|
||||
@@ -21,11 +20,7 @@ import {
|
||||
isDevMode,
|
||||
} from '@angular/core';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import {
|
||||
provideRouter,
|
||||
TitleStrategy,
|
||||
withComponentInputBinding,
|
||||
} from '@angular/router';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
import { ActionReducer, MetaReducer, provideStore } from '@ngrx/store';
|
||||
import { provideStoreDevtools } from '@ngrx/store-devtools';
|
||||
|
||||
@@ -58,14 +53,7 @@ import {
|
||||
} from '@adapter/scan';
|
||||
import * as Commands from './commands';
|
||||
import { NativeContainerService } from '@external/native-container';
|
||||
import { ShellModule } from '@shared/shell';
|
||||
import { IconModule } from '@shared/components/icon';
|
||||
import { NgIconsModule } from '@ng-icons/core';
|
||||
import {
|
||||
matClose,
|
||||
matWifi,
|
||||
matWifiOff,
|
||||
} from '@ng-icons/material-icons/baseline';
|
||||
import { NetworkStatusService } from '@isa/core/connectivity';
|
||||
import { debounceTime, filter, firstValueFrom, switchMap } from 'rxjs';
|
||||
import { provideMatomo, withRouter, withRouteData } from 'ngx-matomo-client';
|
||||
@@ -86,7 +74,6 @@ import { Store } from '@ngrx/store';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
import z from 'zod';
|
||||
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
|
||||
import { TabNavigationService } from '@isa/core/tabs';
|
||||
|
||||
// Domain modules
|
||||
import { provideDomainCheckout } from '@domain/checkout';
|
||||
@@ -103,6 +90,7 @@ import { PrintConfiguration } from '@generated/swagger/print-api';
|
||||
import { RemiConfiguration } from '@generated/swagger/inventory-api';
|
||||
import { WwsConfiguration } from '@generated/swagger/wws-api';
|
||||
import { UiIconModule } from '@ui/icon';
|
||||
import { provideCoreTabs } from '@isa/core/tabs';
|
||||
|
||||
// --- Store Configuration ---
|
||||
|
||||
@@ -139,7 +127,7 @@ function appInitializerFactory(_config: Config, injector: Injector) {
|
||||
return async () => {
|
||||
const logger = loggerFactory(() => ({ service: 'AppInitializer' }));
|
||||
const statusElement = document.querySelector('#init-status');
|
||||
const laoderElement = document.querySelector('#init-loader');
|
||||
const loaderElement = document.querySelector('#init-loader');
|
||||
|
||||
try {
|
||||
logger.info('Starting application initialization');
|
||||
@@ -221,14 +209,11 @@ function appInitializerFactory(_config: Config, injector: Injector) {
|
||||
.subscribe((state) => {
|
||||
userStorage.set('store', state);
|
||||
});
|
||||
|
||||
logger.info('Application initialization completed');
|
||||
injector.get(TabNavigationService).init();
|
||||
} catch (error) {
|
||||
logger.error('Application initialization failed', error as Error, () => ({
|
||||
message: (error as Error).message,
|
||||
}));
|
||||
laoderElement.remove();
|
||||
loaderElement?.remove();
|
||||
statusElement.classList.add('text-xl');
|
||||
statusElement.innerHTML +=
|
||||
'⚡<br><br><b>Fehler bei der Initialisierung</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br><br>';
|
||||
@@ -414,7 +399,7 @@ export const appConfig: ApplicationConfig = {
|
||||
provideUserSubFactory(USER_SUB_FACTORY),
|
||||
|
||||
// Title strategy
|
||||
{ provide: TitleStrategy, useClass: IsaTitleStrategy },
|
||||
provideCoreTabs(),
|
||||
|
||||
// Import providers from NgModules
|
||||
importProvidersFrom(
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
OnInit,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AuthService } from '@core/auth';
|
||||
|
||||
@@ -7,25 +12,24 @@ import { AuthService } from '@core/auth';
|
||||
templateUrl: 'token-login.component.html',
|
||||
styleUrls: ['token-login.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
})
|
||||
export class TokenLoginComponent implements OnInit {
|
||||
constructor(
|
||||
private _route: ActivatedRoute,
|
||||
private _authService: AuthService,
|
||||
private _router: Router,
|
||||
) {}
|
||||
readonly #route = inject(ActivatedRoute);
|
||||
readonly #authService = inject(AuthService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
ngOnInit() {
|
||||
if (
|
||||
this._route.snapshot.params.token &&
|
||||
!this._authService.isAuthenticated()
|
||||
this.#route.snapshot.params.token &&
|
||||
!this.#authService.isAuthenticated()
|
||||
) {
|
||||
this._authService.setKeyCardToken(this._route.snapshot.params.token);
|
||||
this._authService.login();
|
||||
} else if (!this._authService.isAuthenticated()) {
|
||||
this._authService.login();
|
||||
} else if (this._authService.isAuthenticated()) {
|
||||
this._router.navigate(['/']);
|
||||
this.#authService.setKeyCardToken(this.#route.snapshot.params.token);
|
||||
this.#authService.login();
|
||||
} else if (!this.#authService.isAuthenticated()) {
|
||||
this.#authService.login();
|
||||
} else if (this.#authService.isAuthenticated()) {
|
||||
this.#router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
OrderListItemDTO,
|
||||
OrderService,
|
||||
ReceiptService,
|
||||
ReceiptType,
|
||||
StatusValues,
|
||||
StockStatusCodeService,
|
||||
ValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO,
|
||||
@@ -68,7 +69,10 @@ export class DomainOmsService {
|
||||
return this.receiptService
|
||||
.ReceiptGetReceiptsByOrderItemSubset({
|
||||
payload: {
|
||||
receiptType: 65 as unknown as any,
|
||||
// 65 represents a combination of receipt type flags (1 + 64)
|
||||
// that the backend accepts but is not part of the generated ReceiptType union.
|
||||
// TODO: Update swagger spec to include combined flag values.
|
||||
receiptType: 65 as ReceiptType,
|
||||
ids: orderItemSubsetIds,
|
||||
eagerLoading: 1,
|
||||
},
|
||||
|
||||
@@ -54,6 +54,8 @@ import {
|
||||
NavigateAfterRewardSelection,
|
||||
RewardSelectionPopUpService,
|
||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { useTabSubtitle } from '@isa/core/tabs';
|
||||
|
||||
@Component({
|
||||
selector: 'page-article-details',
|
||||
@@ -299,6 +301,10 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
|
||||
),
|
||||
);
|
||||
|
||||
processId = toSignal(this.applicationService.activatedProcessId$);
|
||||
|
||||
item = toSignal(this.store.item$);
|
||||
|
||||
constructor(
|
||||
public readonly applicationService: ApplicationService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
@@ -316,7 +322,9 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
|
||||
private _router: Router,
|
||||
private _domainCheckoutService: DomainCheckoutService,
|
||||
private _store: Store,
|
||||
) {}
|
||||
) {
|
||||
useTabSubtitle(this.item, (item) => item?.product?.name);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const processIdSubscription = this.activatedRoute.parent.params
|
||||
|
||||
@@ -1,180 +1,228 @@
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { ApplicationService } from '@core/application';
|
||||
import { EnvironmentService } from '@core/environment';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { map, takeUntil, withLatestFrom } from 'rxjs/operators';
|
||||
import { ArticleSearchService } from '../article-search.store';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ProductCatalogNavigationService } from '@shared/services/navigation';
|
||||
import { Filter, FilterComponent } from '@shared/components/filter';
|
||||
|
||||
@Component({
|
||||
selector: 'page-article-search-filter',
|
||||
templateUrl: 'search-filter.component.html',
|
||||
styleUrls: ['search-filter.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class ArticleSearchFilterComponent implements OnInit, OnDestroy {
|
||||
_processId$ = this._activatedRoute.parent.data.pipe(map((data) => Number(data.processId)));
|
||||
|
||||
fetching$: Observable<boolean> = this.articleSearch.fetching$;
|
||||
|
||||
filter$: Observable<Filter> = this.articleSearch.filter$;
|
||||
|
||||
searchboxHint$ = this.articleSearch.searchboxHint$;
|
||||
|
||||
@ViewChild(FilterComponent, { static: false })
|
||||
uiFilterComponent: FilterComponent;
|
||||
|
||||
showFilter: boolean = false;
|
||||
|
||||
get isTablet() {
|
||||
return this._environment.matchTablet();
|
||||
}
|
||||
|
||||
get showFilterClose$() {
|
||||
return this._environment.matchDesktopLarge$.pipe(map((matches) => !(matches && this.sideOutlet === 'search')));
|
||||
}
|
||||
|
||||
get sideOutlet() {
|
||||
return this._activatedRoute?.parent?.children?.find((childRoute) => childRoute?.outlet === 'side')?.snapshot
|
||||
?.routeConfig?.path;
|
||||
}
|
||||
|
||||
get primaryOutlet() {
|
||||
return this._activatedRoute?.parent?.children?.find((childRoute) => childRoute?.outlet === 'primary')?.snapshot
|
||||
?.routeConfig?.path;
|
||||
}
|
||||
|
||||
get closeFilterRoute() {
|
||||
const processId = Number(this._activatedRoute?.parent?.snapshot?.data?.processId);
|
||||
const itemId = this._activatedRoute.snapshot.params.id;
|
||||
if (!itemId) {
|
||||
if (this.sideOutlet === 'search') {
|
||||
return this._navigationService.getArticleSearchBasePath(processId).path;
|
||||
} else if (this.primaryOutlet === 'results' || this.sideOutlet === 'results') {
|
||||
return this._navigationService.getArticleSearchResultsPath(processId).path;
|
||||
}
|
||||
} else {
|
||||
return this._navigationService.getArticleDetailsPath({ processId, itemId }).path;
|
||||
}
|
||||
}
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private articleSearch: ArticleSearchService,
|
||||
private _environment: EnvironmentService,
|
||||
private _activatedRoute: ActivatedRoute,
|
||||
public application: ApplicationService,
|
||||
private _navigationService: ProductCatalogNavigationService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.showFilter = this.sideOutlet !== 'search';
|
||||
this._activatedRoute.queryParams
|
||||
.pipe(takeUntil(this._onDestroy$))
|
||||
.subscribe(async (queryParams) => await this.articleSearch.setDefaultFilter(queryParams));
|
||||
|
||||
// #4143 To make Splitscreen Search and Filter work combined
|
||||
this.articleSearch.searchStarted.pipe(takeUntil(this._onDestroy$)).subscribe(async (_) => {
|
||||
let queryParams = {
|
||||
...this.articleSearch.filter.getQueryParams(),
|
||||
...this.cleanupQueryParams(this.uiFilterComponent?.uiFilter?.getQueryParams()),
|
||||
};
|
||||
|
||||
// Always override query if not in tablet mode
|
||||
if (!!this.articleSearch.filter.getQueryParams()?.main_qs && !this.isTablet) {
|
||||
queryParams = { ...queryParams, main_qs: this.articleSearch.filter.getQueryParams()?.main_qs };
|
||||
}
|
||||
|
||||
await this.articleSearch.setDefaultFilter(queryParams);
|
||||
});
|
||||
|
||||
this.articleSearch.searchCompleted
|
||||
.pipe(takeUntil(this._onDestroy$), withLatestFrom(this._processId$))
|
||||
.subscribe(async ([searchCompleted, processId]) => {
|
||||
if (searchCompleted.state.searchState === '') {
|
||||
const params = searchCompleted.state.filter.getQueryParams();
|
||||
if (searchCompleted.state.hits === 1) {
|
||||
const item = searchCompleted.state.items.find((f) => f);
|
||||
await this._navigationService
|
||||
.getArticleDetailsPath({
|
||||
processId,
|
||||
itemId: item.id,
|
||||
extras: { queryParams: params },
|
||||
})
|
||||
.navigate();
|
||||
} else {
|
||||
await this._navigationService.getArticleSearchResultsPath(processId, { queryParams: params }).navigate();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
}
|
||||
|
||||
applyFilter(value: Filter) {
|
||||
this.uiFilterComponent?.cancelAutocomplete();
|
||||
this.articleSearch.search({ clear: true });
|
||||
this.articleSearch.searchCompleted
|
||||
.pipe(takeUntil(this._onDestroy$), withLatestFrom(this._processId$))
|
||||
.subscribe(async ([searchCompleted, processId]) => {
|
||||
if (searchCompleted.state.searchState === '') {
|
||||
const params = searchCompleted.state.filter.getQueryParams();
|
||||
if (searchCompleted.state.hits === 1) {
|
||||
const item = searchCompleted.state.items.find((f) => f);
|
||||
await this._navigationService
|
||||
.getArticleDetailsPath({
|
||||
processId,
|
||||
itemId: item.id,
|
||||
extras: { queryParams: params },
|
||||
})
|
||||
.navigate();
|
||||
} else {
|
||||
await this._navigationService.getArticleSearchResultsPath(processId, { queryParams: params }).navigate();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearFilter(value: Filter) {
|
||||
value.unselectAllFilterOptions();
|
||||
}
|
||||
|
||||
hasSelectedOptions(filter: Filter) {
|
||||
// Is Query available
|
||||
const hasInputOptions = !!filter.input.find(
|
||||
(input) => !!input.input.find((fi) => fi.hasFilterInputOptionsSelected()),
|
||||
);
|
||||
|
||||
// Are filter or filterChips selected
|
||||
const hasFilterOptions = !!filter.filter.find(
|
||||
(input) => !!input.input.find((fi) => fi.hasFilterInputOptionsSelected()),
|
||||
);
|
||||
return hasInputOptions || hasFilterOptions;
|
||||
}
|
||||
|
||||
resetFilter(value: Filter) {
|
||||
const queryParams = { main_qs: value?.getQueryParams()?.main_qs || '' };
|
||||
this.articleSearch.setDefaultFilter(queryParams);
|
||||
}
|
||||
|
||||
cleanupQueryParams(params: Record<string, string> = {}) {
|
||||
const clean = { ...params };
|
||||
|
||||
for (const key in clean) {
|
||||
if (Object.prototype.hasOwnProperty.call(clean, key)) {
|
||||
if (clean[key] == undefined) {
|
||||
delete clean[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
}
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { ApplicationService } from '@core/application';
|
||||
import { EnvironmentService } from '@core/environment';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { map, takeUntil, withLatestFrom } from 'rxjs/operators';
|
||||
import { ArticleSearchService } from '../article-search.store';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ProductCatalogNavigationService } from '@shared/services/navigation';
|
||||
import { Filter, FilterComponent } from '@shared/components/filter';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { useQueryParamSubtitle } from '@isa/core/tabs';
|
||||
|
||||
@Component({
|
||||
selector: 'page-article-search-filter',
|
||||
templateUrl: 'search-filter.component.html',
|
||||
styleUrls: ['search-filter.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class ArticleSearchFilterComponent implements OnInit, OnDestroy {
|
||||
_processId$ = this._activatedRoute.parent.data.pipe(
|
||||
map((data) => Number(data.processId)),
|
||||
);
|
||||
processId = toSignal(this._processId$);
|
||||
|
||||
fetching$: Observable<boolean> = this.articleSearch.fetching$;
|
||||
|
||||
filter$: Observable<Filter> = this.articleSearch.filter$;
|
||||
|
||||
filter = toSignal(this.filter$);
|
||||
|
||||
filterParams = computed(() => this.filter()?.getQueryParams());
|
||||
|
||||
searchboxHint$ = this.articleSearch.searchboxHint$;
|
||||
|
||||
@ViewChild(FilterComponent, { static: false })
|
||||
uiFilterComponent: FilterComponent;
|
||||
|
||||
showFilter = false;
|
||||
|
||||
get isTablet() {
|
||||
return this._environment.matchTablet();
|
||||
}
|
||||
|
||||
get showFilterClose$() {
|
||||
return this._environment.matchDesktopLarge$.pipe(
|
||||
map((matches) => !(matches && this.sideOutlet === 'search')),
|
||||
);
|
||||
}
|
||||
|
||||
get sideOutlet() {
|
||||
return this._activatedRoute?.parent?.children?.find(
|
||||
(childRoute) => childRoute?.outlet === 'side',
|
||||
)?.snapshot?.routeConfig?.path;
|
||||
}
|
||||
|
||||
get primaryOutlet() {
|
||||
return this._activatedRoute?.parent?.children?.find(
|
||||
(childRoute) => childRoute?.outlet === 'primary',
|
||||
)?.snapshot?.routeConfig?.path;
|
||||
}
|
||||
|
||||
get closeFilterRoute() {
|
||||
const processId = Number(
|
||||
this._activatedRoute?.parent?.snapshot?.data?.processId,
|
||||
);
|
||||
const itemId = this._activatedRoute.snapshot.params.id;
|
||||
if (!itemId) {
|
||||
if (this.sideOutlet === 'search') {
|
||||
return this._navigationService.getArticleSearchBasePath(processId).path;
|
||||
} else if (
|
||||
this.primaryOutlet === 'results' ||
|
||||
this.sideOutlet === 'results'
|
||||
) {
|
||||
return this._navigationService.getArticleSearchResultsPath(processId)
|
||||
.path;
|
||||
}
|
||||
} else {
|
||||
return this._navigationService.getArticleDetailsPath({
|
||||
processId,
|
||||
itemId,
|
||||
}).path;
|
||||
}
|
||||
}
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private articleSearch: ArticleSearchService,
|
||||
private _environment: EnvironmentService,
|
||||
private _activatedRoute: ActivatedRoute,
|
||||
public application: ApplicationService,
|
||||
private _navigationService: ProductCatalogNavigationService,
|
||||
) {
|
||||
useQueryParamSubtitle(this.filterParams, 'main_qs', 'Filter');
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.showFilter = this.sideOutlet !== 'search';
|
||||
this._activatedRoute.queryParams
|
||||
.pipe(takeUntil(this._onDestroy$))
|
||||
.subscribe(
|
||||
async (queryParams) =>
|
||||
await this.articleSearch.setDefaultFilter(queryParams),
|
||||
);
|
||||
|
||||
// #4143 To make Splitscreen Search and Filter work combined
|
||||
this.articleSearch.searchStarted
|
||||
.pipe(takeUntil(this._onDestroy$))
|
||||
.subscribe(async (_) => {
|
||||
let queryParams = {
|
||||
...this.articleSearch.filter.getQueryParams(),
|
||||
...this.cleanupQueryParams(
|
||||
this.uiFilterComponent?.uiFilter?.getQueryParams(),
|
||||
),
|
||||
};
|
||||
|
||||
// Always override query if not in tablet mode
|
||||
if (
|
||||
!!this.articleSearch.filter.getQueryParams()?.main_qs &&
|
||||
!this.isTablet
|
||||
) {
|
||||
queryParams = {
|
||||
...queryParams,
|
||||
main_qs: this.articleSearch.filter.getQueryParams()?.main_qs,
|
||||
};
|
||||
}
|
||||
|
||||
await this.articleSearch.setDefaultFilter(queryParams);
|
||||
});
|
||||
|
||||
this.articleSearch.searchCompleted
|
||||
.pipe(takeUntil(this._onDestroy$), withLatestFrom(this._processId$))
|
||||
.subscribe(async ([searchCompleted, processId]) => {
|
||||
if (searchCompleted.state.searchState === '') {
|
||||
const params = searchCompleted.state.filter.getQueryParams();
|
||||
if (searchCompleted.state.hits === 1) {
|
||||
const item = searchCompleted.state.items.find((f) => f);
|
||||
await this._navigationService
|
||||
.getArticleDetailsPath({
|
||||
processId,
|
||||
itemId: item.id,
|
||||
extras: { queryParams: params },
|
||||
})
|
||||
.navigate();
|
||||
} else {
|
||||
await this._navigationService
|
||||
.getArticleSearchResultsPath(processId, { queryParams: params })
|
||||
.navigate();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
}
|
||||
|
||||
applyFilter(value: Filter) {
|
||||
this.uiFilterComponent?.cancelAutocomplete();
|
||||
this.articleSearch.search({ clear: true });
|
||||
this.articleSearch.searchCompleted
|
||||
.pipe(takeUntil(this._onDestroy$), withLatestFrom(this._processId$))
|
||||
.subscribe(async ([searchCompleted, processId]) => {
|
||||
if (searchCompleted.state.searchState === '') {
|
||||
const params = searchCompleted.state.filter.getQueryParams();
|
||||
if (searchCompleted.state.hits === 1) {
|
||||
const item = searchCompleted.state.items.find((f) => f);
|
||||
await this._navigationService
|
||||
.getArticleDetailsPath({
|
||||
processId,
|
||||
itemId: item.id,
|
||||
extras: { queryParams: params },
|
||||
})
|
||||
.navigate();
|
||||
} else {
|
||||
await this._navigationService
|
||||
.getArticleSearchResultsPath(processId, { queryParams: params })
|
||||
.navigate();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearFilter(value: Filter) {
|
||||
value.unselectAllFilterOptions();
|
||||
}
|
||||
|
||||
hasSelectedOptions(filter: Filter) {
|
||||
// Is Query available
|
||||
const hasInputOptions = !!filter.input.find(
|
||||
(input) => !!input.input.find((fi) => fi.hasFilterInputOptionsSelected()),
|
||||
);
|
||||
|
||||
// Are filter or filterChips selected
|
||||
const hasFilterOptions = !!filter.filter.find(
|
||||
(input) => !!input.input.find((fi) => fi.hasFilterInputOptionsSelected()),
|
||||
);
|
||||
return hasInputOptions || hasFilterOptions;
|
||||
}
|
||||
|
||||
resetFilter(value: Filter) {
|
||||
const queryParams = { main_qs: value?.getQueryParams()?.main_qs || '' };
|
||||
this.articleSearch.setDefaultFilter(queryParams);
|
||||
}
|
||||
|
||||
cleanupQueryParams(params: Record<string, string> = {}) {
|
||||
const clean = { ...params };
|
||||
|
||||
for (const key in clean) {
|
||||
if (Object.prototype.hasOwnProperty.call(clean, key)) {
|
||||
if (clean[key] == undefined) {
|
||||
delete clean[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'filter',
|
||||
component: ArticleSearchFilterComponent,
|
||||
title: 'Artikelsuche - Filter',
|
||||
title: 'Artikelsuche',
|
||||
data: {
|
||||
matomo: {
|
||||
title: 'Artikelsuche - Filter',
|
||||
@@ -32,7 +32,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'filter/:id',
|
||||
component: ArticleSearchFilterComponent,
|
||||
title: 'Artikelsuche - Filter',
|
||||
title: 'Artikelsuche',
|
||||
data: {
|
||||
matomo: {
|
||||
title: 'Artikelsuche - Filter',
|
||||
@@ -59,7 +59,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'results',
|
||||
component: ArticleSearchResultsComponent,
|
||||
title: 'Artikelsuche - Trefferliste',
|
||||
title: 'Artikelsuche',
|
||||
data: {
|
||||
matomo: {
|
||||
title: 'Artikelsuche - Trefferliste',
|
||||
@@ -70,7 +70,7 @@ const routes: Routes = [
|
||||
path: 'results',
|
||||
component: ArticleSearchResultsComponent,
|
||||
outlet: 'side',
|
||||
title: 'Artikelsuche - Trefferliste',
|
||||
title: 'Artikelsuche',
|
||||
data: {
|
||||
matomo: {
|
||||
title: 'Artikelsuche - Trefferliste',
|
||||
@@ -81,7 +81,7 @@ const routes: Routes = [
|
||||
path: 'results/:id',
|
||||
component: ArticleSearchResultsComponent,
|
||||
outlet: 'side',
|
||||
title: 'Artikelsuche - Artikeldetails',
|
||||
title: 'Artikelsuche',
|
||||
data: {
|
||||
matomo: {
|
||||
title: 'Artikelsuche - Artikeldetails',
|
||||
@@ -92,7 +92,7 @@ const routes: Routes = [
|
||||
path: 'results/:ean/ean',
|
||||
component: ArticleSearchResultsComponent,
|
||||
outlet: 'side',
|
||||
title: 'Artikelsuche - Artikeldetails (EAN)',
|
||||
title: 'Artikelsuche',
|
||||
data: {
|
||||
matomo: {
|
||||
title: 'Artikelsuche - Artikeldetails (EAN)',
|
||||
@@ -102,7 +102,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'details/:id',
|
||||
component: ArticleDetailsComponent,
|
||||
title: 'Artikelsuche - Artikeldetails (ID)',
|
||||
title: 'Artikelsuche',
|
||||
data: {
|
||||
matomo: {
|
||||
title: 'Artikelsuche - Artikeldetails (ID)',
|
||||
@@ -112,7 +112,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'details/:ean/ean',
|
||||
component: ArticleDetailsComponent,
|
||||
title: 'Artikelsuche - Artikeldetails (EAN)',
|
||||
title: 'Artikelsuche',
|
||||
data: {
|
||||
matomo: {
|
||||
title: 'Artikelsuche - Artikeldetails (EAN)',
|
||||
|
||||
@@ -1,187 +1,252 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core';
|
||||
import {
|
||||
emailNotificationValidator,
|
||||
mobileNotificationValidator,
|
||||
} from '@shared/components/notification-channel-control';
|
||||
import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { CheckoutReviewStore } from '../checkout-review.store';
|
||||
import { first, map, switchMap, takeUntil, withLatestFrom } from 'rxjs/operators';
|
||||
import { ApplicationService } from '@core/application';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { Router } from '@angular/router';
|
||||
import { BuyerDTO, NotificationChannel } from '@generated/swagger/checkout-api';
|
||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
||||
|
||||
@Component({
|
||||
selector: 'page-checkout-review-details',
|
||||
templateUrl: 'checkout-review-details.component.html',
|
||||
styleUrls: ['checkout-review-details.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class CheckoutReviewDetailsComponent implements OnInit {
|
||||
customerNavigation = inject(CustomerSearchNavigation);
|
||||
|
||||
control: UntypedFormGroup;
|
||||
|
||||
customerFeatures$ = this._store.customerFeatures$;
|
||||
|
||||
payer$ = this._store.payer$;
|
||||
buyer$ = this._store.buyer$;
|
||||
|
||||
showNotificationChannels$ = combineLatest([this._store.shoppingCartItems$, this.payer$, this.buyer$]).pipe(
|
||||
takeUntil(this._store.orderCompleted),
|
||||
map(
|
||||
([items, payer, buyer]) =>
|
||||
(!!payer || !!buyer) &&
|
||||
items.some((item) => item.features?.orderType === 'Rücklage' || item.features?.orderType === 'Abholung'),
|
||||
),
|
||||
);
|
||||
|
||||
notificationChannel$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this._store.orderCompleted),
|
||||
switchMap((processId) => this._domainCheckoutService.getNotificationChannels({ processId })),
|
||||
);
|
||||
|
||||
communicationDetails$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this._store.orderCompleted),
|
||||
switchMap((processId) => this._domainCheckoutService.getBuyerCommunicationDetails({ processId })),
|
||||
map((communicationDetails) => communicationDetails ?? { email: undefined, mobile: undefined }),
|
||||
);
|
||||
|
||||
specialComment$ = this._application.activatedProcessId$.pipe(
|
||||
switchMap((processId) => this._domainCheckoutService.getSpecialComment({ processId })),
|
||||
);
|
||||
|
||||
shippingAddress$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this._store.orderCompleted),
|
||||
switchMap((processId) => this._domainCheckoutService.getShippingAddress({ processId })),
|
||||
);
|
||||
|
||||
showAddresses$ = this._store.shoppingCartItems$.pipe(
|
||||
takeUntil(this._store.orderCompleted),
|
||||
withLatestFrom(this.customerFeatures$, this.payer$, this.shippingAddress$),
|
||||
map(([items, customerFeatures, payer, shippingAddress]) => {
|
||||
const hasShippingOrBillingAddresses = !!payer?.address || !!shippingAddress;
|
||||
const hasShippingFeature = items.some(
|
||||
(item) =>
|
||||
item.features?.orderType === 'Versand' ||
|
||||
item.features?.orderType === 'B2B-Versand' ||
|
||||
item.features?.orderType === 'DIG-Versand',
|
||||
);
|
||||
|
||||
const isB2bCustomer = !!customerFeatures?.b2b;
|
||||
|
||||
return hasShippingOrBillingAddresses && (hasShippingFeature || isB2bCustomer);
|
||||
}),
|
||||
);
|
||||
|
||||
notificationChannelLoading$ = this._store.notificationChannelLoading$;
|
||||
|
||||
constructor(
|
||||
private _fb: UntypedFormBuilder,
|
||||
private _store: CheckoutReviewStore,
|
||||
private _application: ApplicationService,
|
||||
private _domainCheckoutService: DomainCheckoutService,
|
||||
private _router: Router,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.initNotificationsControl();
|
||||
}
|
||||
|
||||
async initNotificationsControl() {
|
||||
const fb = this._fb;
|
||||
const notificationChannel = await this.notificationChannel$.pipe(first()).toPromise();
|
||||
const communicationDetails = await this.communicationDetails$.pipe(first()).toPromise();
|
||||
|
||||
let selectedNotificationChannel = 0;
|
||||
if ((notificationChannel & 1) === 1 && communicationDetails.email) {
|
||||
selectedNotificationChannel += 1;
|
||||
}
|
||||
if ((notificationChannel & 2) === 2 && communicationDetails.mobile) {
|
||||
selectedNotificationChannel += 2;
|
||||
}
|
||||
// #1967 Wenn E-Mail und SMS als NotificationChannel gesetzt sind, nur E-Mail anhaken
|
||||
if ((selectedNotificationChannel & 3) === 3) {
|
||||
selectedNotificationChannel = 1;
|
||||
}
|
||||
|
||||
if (!this._store.notificationsControl) {
|
||||
this.control = fb.group({
|
||||
notificationChannel: new UntypedFormGroup({
|
||||
selected: new UntypedFormControl(selectedNotificationChannel),
|
||||
email: new UntypedFormControl(
|
||||
communicationDetails ? communicationDetails.email : '',
|
||||
emailNotificationValidator,
|
||||
),
|
||||
mobile: new UntypedFormControl(
|
||||
communicationDetails ? communicationDetails.mobile : '',
|
||||
mobileNotificationValidator,
|
||||
),
|
||||
}),
|
||||
});
|
||||
this._store.notificationsControl = this.control;
|
||||
} else {
|
||||
this.control = this._store.notificationsControl;
|
||||
}
|
||||
}
|
||||
|
||||
setAgentComment(agentComment: string) {
|
||||
this._domainCheckoutService.setSpecialComment({ processId: this._application.activatedProcessId, agentComment });
|
||||
}
|
||||
|
||||
updateNotifications(notificationChannels?: NotificationChannel[]) {
|
||||
this._store.onNotificationChange(notificationChannels);
|
||||
}
|
||||
|
||||
getNameFromBuyer(buyer: BuyerDTO): { value: string; label: string } {
|
||||
if (buyer?.lastName && buyer?.firstName) {
|
||||
return { value: `${buyer?.lastName}, ${buyer?.firstName}`, label: 'Nachname, Vorname' };
|
||||
} else if (buyer?.lastName) {
|
||||
return { value: buyer?.lastName, label: 'Nachname, Vorname' };
|
||||
} else if (buyer?.firstName) {
|
||||
return { value: buyer?.firstName, label: 'Nachname, Vorname' };
|
||||
} else if (buyer?.organisation?.name) {
|
||||
return { value: buyer?.organisation?.name, label: 'Firmenname' };
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async changeAddress() {
|
||||
const processId = this._application.activatedProcessId;
|
||||
const customer = await this._domainCheckoutService.getBuyer({ processId }).pipe(first()).toPromise();
|
||||
if (!customer) {
|
||||
this.navigateToCustomerSearch(processId);
|
||||
return;
|
||||
}
|
||||
const customerId = customer.source;
|
||||
await this.customerNavigation.navigateToDetails({
|
||||
processId,
|
||||
customerId,
|
||||
customer: { customerNumber: customer.buyerNumber },
|
||||
});
|
||||
// this._router.navigate(['/kunde', this._application.activatedProcessId, 'customer', 'search', `${customerId}`]);
|
||||
}
|
||||
|
||||
async navigateToCustomerSearch(processId: number) {
|
||||
try {
|
||||
const response = await this.customerFeatures$
|
||||
.pipe(
|
||||
first(),
|
||||
switchMap((customerFeatures) => {
|
||||
return this._domainCheckoutService.canSetCustomer({ processId, customerFeatures });
|
||||
}),
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
this._router.navigate(['/kunde', this._application.activatedProcessId, 'customer', 'search'], {
|
||||
queryParams: { filter_customertype: response.filter.customertype },
|
||||
});
|
||||
} catch (error) {
|
||||
this._router.navigate(['/kunde', this._application.activatedProcessId, 'customer', 'search']);
|
||||
}
|
||||
}
|
||||
}
|
||||
import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core';
|
||||
import {
|
||||
emailNotificationValidator,
|
||||
mobileNotificationValidator,
|
||||
} from '@shared/components/notification-channel-control';
|
||||
import {
|
||||
UntypedFormBuilder,
|
||||
UntypedFormControl,
|
||||
UntypedFormGroup,
|
||||
} from '@angular/forms';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { CheckoutReviewStore } from '../checkout-review.store';
|
||||
import {
|
||||
first,
|
||||
map,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
withLatestFrom,
|
||||
} from 'rxjs/operators';
|
||||
import { ApplicationService } from '@core/application';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { Router } from '@angular/router';
|
||||
import { BuyerDTO, NotificationChannel } from '@generated/swagger/checkout-api';
|
||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { useTabSubtitle } from '@isa/core/tabs';
|
||||
import { getCustomerName } from '@isa/crm/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'page-checkout-review-details',
|
||||
templateUrl: 'checkout-review-details.component.html',
|
||||
styleUrls: ['checkout-review-details.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class CheckoutReviewDetailsComponent implements OnInit {
|
||||
customerNavigation = inject(CustomerSearchNavigation);
|
||||
|
||||
control: UntypedFormGroup;
|
||||
|
||||
customerFeatures$ = this._store.customerFeatures$;
|
||||
|
||||
payer$ = this._store.payer$;
|
||||
buyer$ = this._store.buyer$;
|
||||
|
||||
showNotificationChannels$ = combineLatest([
|
||||
this._store.shoppingCartItems$,
|
||||
this.payer$,
|
||||
this.buyer$,
|
||||
]).pipe(
|
||||
takeUntil(this._store.orderCompleted),
|
||||
map(
|
||||
([items, payer, buyer]) =>
|
||||
(!!payer || !!buyer) &&
|
||||
items.some(
|
||||
(item) =>
|
||||
item.features?.orderType === 'Rücklage' ||
|
||||
item.features?.orderType === 'Abholung',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
notificationChannel$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this._store.orderCompleted),
|
||||
switchMap((processId) =>
|
||||
this._domainCheckoutService.getNotificationChannels({ processId }),
|
||||
),
|
||||
);
|
||||
|
||||
communicationDetails$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this._store.orderCompleted),
|
||||
switchMap((processId) =>
|
||||
this._domainCheckoutService.getBuyerCommunicationDetails({ processId }),
|
||||
),
|
||||
map(
|
||||
(communicationDetails) =>
|
||||
communicationDetails ?? { email: undefined, mobile: undefined },
|
||||
),
|
||||
);
|
||||
|
||||
specialComment$ = this._application.activatedProcessId$.pipe(
|
||||
switchMap((processId) =>
|
||||
this._domainCheckoutService.getSpecialComment({ processId }),
|
||||
),
|
||||
);
|
||||
|
||||
shippingAddress$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this._store.orderCompleted),
|
||||
switchMap((processId) =>
|
||||
this._domainCheckoutService.getShippingAddress({ processId }),
|
||||
),
|
||||
);
|
||||
|
||||
showAddresses$ = this._store.shoppingCartItems$.pipe(
|
||||
takeUntil(this._store.orderCompleted),
|
||||
withLatestFrom(this.customerFeatures$, this.payer$, this.shippingAddress$),
|
||||
map(([items, customerFeatures, payer, shippingAddress]) => {
|
||||
const hasShippingOrBillingAddresses =
|
||||
!!payer?.address || !!shippingAddress;
|
||||
const hasShippingFeature = items.some(
|
||||
(item) =>
|
||||
item.features?.orderType === 'Versand' ||
|
||||
item.features?.orderType === 'B2B-Versand' ||
|
||||
item.features?.orderType === 'DIG-Versand',
|
||||
);
|
||||
|
||||
const isB2bCustomer = !!customerFeatures?.b2b;
|
||||
|
||||
return (
|
||||
hasShippingOrBillingAddresses && (hasShippingFeature || isB2bCustomer)
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
notificationChannelLoading$ = this._store.notificationChannelLoading$;
|
||||
|
||||
processId = toSignal(this._application.activatedProcessId$);
|
||||
|
||||
buyer = toSignal(this.buyer$);
|
||||
|
||||
constructor(
|
||||
private _fb: UntypedFormBuilder,
|
||||
private _store: CheckoutReviewStore,
|
||||
private _application: ApplicationService,
|
||||
private _domainCheckoutService: DomainCheckoutService,
|
||||
private _router: Router,
|
||||
) {
|
||||
useTabSubtitle(this.buyer, getCustomerName);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.initNotificationsControl();
|
||||
}
|
||||
|
||||
async initNotificationsControl() {
|
||||
const fb = this._fb;
|
||||
const notificationChannel = await this.notificationChannel$
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const communicationDetails = await this.communicationDetails$
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
let selectedNotificationChannel = 0;
|
||||
if ((notificationChannel & 1) === 1 && communicationDetails.email) {
|
||||
selectedNotificationChannel += 1;
|
||||
}
|
||||
if ((notificationChannel & 2) === 2 && communicationDetails.mobile) {
|
||||
selectedNotificationChannel += 2;
|
||||
}
|
||||
// #1967 Wenn E-Mail und SMS als NotificationChannel gesetzt sind, nur E-Mail anhaken
|
||||
if ((selectedNotificationChannel & 3) === 3) {
|
||||
selectedNotificationChannel = 1;
|
||||
}
|
||||
|
||||
if (!this._store.notificationsControl) {
|
||||
this.control = fb.group({
|
||||
notificationChannel: new UntypedFormGroup({
|
||||
selected: new UntypedFormControl(selectedNotificationChannel),
|
||||
email: new UntypedFormControl(
|
||||
communicationDetails ? communicationDetails.email : '',
|
||||
emailNotificationValidator,
|
||||
),
|
||||
mobile: new UntypedFormControl(
|
||||
communicationDetails ? communicationDetails.mobile : '',
|
||||
mobileNotificationValidator,
|
||||
),
|
||||
}),
|
||||
});
|
||||
this._store.notificationsControl = this.control;
|
||||
} else {
|
||||
this.control = this._store.notificationsControl;
|
||||
}
|
||||
}
|
||||
|
||||
setAgentComment(agentComment: string) {
|
||||
this._domainCheckoutService.setSpecialComment({
|
||||
processId: this._application.activatedProcessId,
|
||||
agentComment,
|
||||
});
|
||||
}
|
||||
|
||||
updateNotifications(notificationChannels?: NotificationChannel[]) {
|
||||
this._store.onNotificationChange(notificationChannels);
|
||||
}
|
||||
|
||||
getNameFromBuyer(buyer: BuyerDTO): { value: string; label: string } {
|
||||
if (buyer?.lastName && buyer?.firstName) {
|
||||
return {
|
||||
value: `${buyer?.lastName}, ${buyer?.firstName}`,
|
||||
label: 'Nachname, Vorname',
|
||||
};
|
||||
} else if (buyer?.lastName) {
|
||||
return { value: buyer?.lastName, label: 'Nachname, Vorname' };
|
||||
} else if (buyer?.firstName) {
|
||||
return { value: buyer?.firstName, label: 'Nachname, Vorname' };
|
||||
} else if (buyer?.organisation?.name) {
|
||||
return { value: buyer?.organisation?.name, label: 'Firmenname' };
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async changeAddress() {
|
||||
const processId = this._application.activatedProcessId;
|
||||
const customer = await this._domainCheckoutService
|
||||
.getBuyer({ processId })
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
if (!customer) {
|
||||
this.navigateToCustomerSearch(processId);
|
||||
return;
|
||||
}
|
||||
const customerId = customer.source;
|
||||
await this.customerNavigation.navigateToDetails({
|
||||
processId,
|
||||
customerId,
|
||||
customer: { customerNumber: customer.buyerNumber },
|
||||
});
|
||||
// this._router.navigate(['/kunde', this._application.activatedProcessId, 'customer', 'search', `${customerId}`]);
|
||||
}
|
||||
|
||||
async navigateToCustomerSearch(processId: number) {
|
||||
try {
|
||||
const response = await this.customerFeatures$
|
||||
.pipe(
|
||||
first(),
|
||||
switchMap((customerFeatures) => {
|
||||
return this._domainCheckoutService.canSetCustomer({
|
||||
processId,
|
||||
customerFeatures,
|
||||
});
|
||||
}),
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
this._router.navigate(
|
||||
['/kunde', this._application.activatedProcessId, 'customer', 'search'],
|
||||
{
|
||||
queryParams: { filter_customertype: response.filter.customertype },
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this._router.navigate([
|
||||
'/kunde',
|
||||
this._application.activatedProcessId,
|
||||
'customer',
|
||||
'search',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,46 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CheckoutReviewComponent } from './checkout-review/checkout-review.component';
|
||||
import { CheckoutSummaryComponent } from './checkout-summary/checkout-summary.component';
|
||||
import { PageCheckoutComponent } from './page-checkout.component';
|
||||
import { CheckoutReviewDetailsComponent } from './checkout-review/details/checkout-review-details.component';
|
||||
import { canDeactivateTabCleanup } from '@isa/core/tabs';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: PageCheckoutComponent,
|
||||
children: [
|
||||
{
|
||||
path: 'details',
|
||||
component: CheckoutReviewDetailsComponent,
|
||||
title: 'Bestelldetails',
|
||||
outlet: 'side',
|
||||
},
|
||||
{
|
||||
path: 'review',
|
||||
component: CheckoutReviewComponent,
|
||||
title: 'Bestellung überprüfen',
|
||||
},
|
||||
{
|
||||
path: 'summary',
|
||||
component: CheckoutSummaryComponent,
|
||||
title: 'Bestellübersicht',
|
||||
canDeactivate: [canDeactivateTabCleanup],
|
||||
},
|
||||
{
|
||||
path: 'summary/:orderIds',
|
||||
component: CheckoutSummaryComponent,
|
||||
title: 'Bestellübersicht',
|
||||
canDeactivate: [canDeactivateTabCleanup],
|
||||
},
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'review' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class PageCheckoutRoutingModule {}
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CheckoutReviewComponent } from './checkout-review/checkout-review.component';
|
||||
import { CheckoutSummaryComponent } from './checkout-summary/checkout-summary.component';
|
||||
import { PageCheckoutComponent } from './page-checkout.component';
|
||||
import { CheckoutReviewDetailsComponent } from './checkout-review/details/checkout-review-details.component';
|
||||
import { canDeactivateTabCleanup } from '@isa/core/tabs';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: PageCheckoutComponent,
|
||||
children: [
|
||||
{
|
||||
path: 'details',
|
||||
component: CheckoutReviewDetailsComponent,
|
||||
title: 'Warenkorb',
|
||||
outlet: 'side',
|
||||
},
|
||||
{
|
||||
path: 'review',
|
||||
component: CheckoutReviewComponent,
|
||||
title: 'Warenkorb',
|
||||
},
|
||||
{
|
||||
path: 'summary',
|
||||
component: CheckoutSummaryComponent,
|
||||
title: 'Bestellbestätigung',
|
||||
canDeactivate: [canDeactivateTabCleanup],
|
||||
},
|
||||
{
|
||||
path: 'summary/:orderIds',
|
||||
component: CheckoutSummaryComponent,
|
||||
title: 'Bestellbestätigung',
|
||||
canDeactivate: [canDeactivateTabCleanup],
|
||||
},
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'review' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class PageCheckoutRoutingModule {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ import {
|
||||
CrmTabMetadataService,
|
||||
Customer,
|
||||
AssignedPayer,
|
||||
getCustomerName,
|
||||
} from '@isa/crm/data-access';
|
||||
import {
|
||||
CustomerAdapter,
|
||||
@@ -49,7 +50,7 @@ import {
|
||||
NavigateAfterRewardSelection,
|
||||
RewardSelectionPopUpService,
|
||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
import { TabService, useTabSubtitle } from '@isa/core/tabs';
|
||||
import { ShippingAddressDTO as CrmShippingAddressDTO } from '@generated/swagger/crm-api';
|
||||
|
||||
interface SelectCustomerContext {
|
||||
@@ -112,9 +113,8 @@ export class CustomerDetailsViewMainComponent
|
||||
}
|
||||
|
||||
checkHasReturnUrl(): void {
|
||||
const hasContext = !!this._tabService.activatedTab()?.metadata?.[
|
||||
'select-customer'
|
||||
];
|
||||
const hasContext =
|
||||
!!this._tabService.activatedTab()?.metadata?.['select-customer'];
|
||||
this.hasReturnUrl.set(hasContext);
|
||||
}
|
||||
|
||||
@@ -302,6 +302,8 @@ export class CustomerDetailsViewMainComponent
|
||||
this.hasKundenkarte$,
|
||||
]).pipe(map(([type, hasCard]) => type === 'webshop' || hasCard));
|
||||
|
||||
customerSignal = toSignal(this._store.customer$);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
isBusy: false,
|
||||
@@ -309,6 +311,8 @@ export class CustomerDetailsViewMainComponent
|
||||
shippingAddress: undefined,
|
||||
payer: undefined,
|
||||
});
|
||||
|
||||
useTabSubtitle(this.customerSignal, getCustomerName);
|
||||
}
|
||||
|
||||
setIsBusy(isBusy: boolean) {
|
||||
|
||||
@@ -1,77 +1,118 @@
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { CustomerSearchStore } from '../store';
|
||||
import { Filter, FilterModule } from '@shared/components/filter';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { IconComponent } from '@shared/components/icon';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { CustomerSearchNavigation, CustomerCreateNavigation } from '@shared/services/navigation';
|
||||
import { CustomerFilterMainViewModule } from '../filter-main-view/filter-main-view.module';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { CustomerInfoDTO } from '@generated/swagger/crm-api';
|
||||
|
||||
@Component({
|
||||
selector: 'page-customer-main-view',
|
||||
templateUrl: 'main-view.component.html',
|
||||
styleUrls: ['main-view.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'page-customer-main-view' },
|
||||
imports: [AsyncPipe, RouterLink, FilterModule, IconComponent, CustomerFilterMainViewModule],
|
||||
})
|
||||
export class CustomerMainViewComponent {
|
||||
private _searchNavigation = inject(CustomerSearchNavigation);
|
||||
private _customerCreateNavigation = inject(CustomerCreateNavigation);
|
||||
private _store = inject(CustomerSearchStore);
|
||||
private _router = inject(Router);
|
||||
|
||||
filterRoute$ = combineLatest([this._store.processId$, this._store.filter$]).pipe(
|
||||
map(([processId, filter]) => {
|
||||
const route = this._searchNavigation.filterRoute({ processId, comingFrom: this._router.url?.split('?')[0] });
|
||||
route.queryParams = { ...route.queryParams, ...filter?.getQueryParams() };
|
||||
route.urlTree.queryParams = { ...route.urlTree.queryParams, ...filter?.getQueryParams() };
|
||||
return route;
|
||||
}),
|
||||
);
|
||||
|
||||
createRoute$ = combineLatest(this._store.filter$, this._store.processId$).pipe(
|
||||
map(([filter, processId]) => {
|
||||
const queryParams = filter?.getQueryParams();
|
||||
|
||||
let customerInfo: CustomerInfoDTO;
|
||||
|
||||
if (queryParams?.main_qs) {
|
||||
const isMail = queryParams.main_qs.includes('@');
|
||||
customerInfo = {
|
||||
lastName: !isMail ? queryParams.main_qs : undefined,
|
||||
communicationDetails: isMail
|
||||
? {
|
||||
email: queryParams.main_qs,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return this._customerCreateNavigation.createCustomerRoute({ processId, customerInfo });
|
||||
}),
|
||||
);
|
||||
|
||||
filter$ = this._store.filter$;
|
||||
|
||||
hasFilter$ = this.filter$.pipe(
|
||||
map((filter) => {
|
||||
if (!filter) return false;
|
||||
const qt = filter.getQueryToken();
|
||||
return !isEmpty(qt.filter);
|
||||
}),
|
||||
);
|
||||
|
||||
fetching$ = this._store.fetchingCustomerList$;
|
||||
|
||||
message$ = this._store.message$;
|
||||
|
||||
search(filter: Filter) {
|
||||
this._store.setFilter(filter);
|
||||
this._store.search({ resetScrollIndex: true, ignoreRestore: true });
|
||||
}
|
||||
}
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
inject,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { CustomerSearchStore } from '../store';
|
||||
import { Filter, FilterModule } from '@shared/components/filter';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { IconComponent } from '@shared/components/icon';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import {
|
||||
CustomerSearchNavigation,
|
||||
CustomerCreateNavigation,
|
||||
} from '@shared/services/navigation';
|
||||
import { CustomerFilterMainViewModule } from '../filter-main-view/filter-main-view.module';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { CustomerInfoDTO } from '@generated/swagger/crm-api';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { useQueryParamSubtitle } from '@isa/core/tabs';
|
||||
|
||||
@Component({
|
||||
selector: 'page-customer-main-view',
|
||||
templateUrl: 'main-view.component.html',
|
||||
styleUrls: ['main-view.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'page-customer-main-view' },
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
RouterLink,
|
||||
FilterModule,
|
||||
IconComponent,
|
||||
CustomerFilterMainViewModule,
|
||||
],
|
||||
})
|
||||
export class CustomerMainViewComponent {
|
||||
private _searchNavigation = inject(CustomerSearchNavigation);
|
||||
private _customerCreateNavigation = inject(CustomerCreateNavigation);
|
||||
private _store = inject(CustomerSearchStore);
|
||||
private _router = inject(Router);
|
||||
|
||||
filterRoute$ = combineLatest([
|
||||
this._store.processId$,
|
||||
this._store.filter$,
|
||||
]).pipe(
|
||||
map(([processId, filter]) => {
|
||||
const route = this._searchNavigation.filterRoute({
|
||||
processId,
|
||||
comingFrom: this._router.url?.split('?')[0],
|
||||
});
|
||||
route.queryParams = { ...route.queryParams, ...filter?.getQueryParams() };
|
||||
route.urlTree.queryParams = {
|
||||
...route.urlTree.queryParams,
|
||||
...filter?.getQueryParams(),
|
||||
};
|
||||
return route;
|
||||
}),
|
||||
);
|
||||
|
||||
createRoute$ = combineLatest(
|
||||
this._store.filter$,
|
||||
this._store.processId$,
|
||||
).pipe(
|
||||
map(([filter, processId]) => {
|
||||
const queryParams = filter?.getQueryParams();
|
||||
|
||||
let customerInfo: CustomerInfoDTO;
|
||||
|
||||
if (queryParams?.main_qs) {
|
||||
const isMail = queryParams.main_qs.includes('@');
|
||||
customerInfo = {
|
||||
lastName: !isMail ? queryParams.main_qs : undefined,
|
||||
communicationDetails: isMail
|
||||
? {
|
||||
email: queryParams.main_qs,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return this._customerCreateNavigation.createCustomerRoute({
|
||||
processId,
|
||||
customerInfo,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
filter$ = this._store.filter$;
|
||||
|
||||
hasFilter$ = this.filter$.pipe(
|
||||
map((filter) => {
|
||||
if (!filter) return false;
|
||||
const qt = filter.getQueryToken();
|
||||
return !isEmpty(qt.filter);
|
||||
}),
|
||||
);
|
||||
|
||||
fetching$ = this._store.fetchingCustomerList$;
|
||||
|
||||
message$ = this._store.message$;
|
||||
|
||||
processId = toSignal(this._store.processId$);
|
||||
|
||||
filter = toSignal(this.filter$);
|
||||
|
||||
filterParams = computed(() => this.filter()?.getQueryParams());
|
||||
|
||||
constructor() {
|
||||
useQueryParamSubtitle(this.filterParams, 'main_qs', 'Kundensuche');
|
||||
}
|
||||
|
||||
search(filter: Filter) {
|
||||
this._store.setFilter(filter);
|
||||
this._store.search({ resetScrollIndex: true, ignoreRestore: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +1,165 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
AfterContentInit,
|
||||
ViewChild,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { CustomerSearchStore } from '../store/customer-search.store';
|
||||
import { BehaviorSubject, Subject, Subscription, combineLatest, race } from 'rxjs';
|
||||
import { delay, filter, map, take, takeUntil } from 'rxjs/operators';
|
||||
import { CustomerSearchNavigation, NavigationRoute } from '@shared/services/navigation';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { Filter } from '@shared/components/filter';
|
||||
import { CustomerResultListComponent } from '../../components/customer-result-list/customer-result-list.component';
|
||||
import { EnvironmentService } from '@core/environment';
|
||||
import { injectCancelSearch } from '@shared/services/cancel-subject';
|
||||
|
||||
@Component({
|
||||
selector: 'page-customer-results-main-view',
|
||||
templateUrl: 'results-main-view.component.html',
|
||||
styleUrls: ['results-main-view.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class CustomerResultsMainViewComponent implements OnInit, OnDestroy, AfterContentInit {
|
||||
private _store = inject(CustomerSearchStore);
|
||||
private _router = inject(Router);
|
||||
private _navigation = inject(CustomerSearchNavigation);
|
||||
private _environment = inject(EnvironmentService);
|
||||
|
||||
cancelSearch = injectCancelSearch();
|
||||
|
||||
processId$ = this._store.processId$;
|
||||
|
||||
currentUrl$ = new BehaviorSubject<string>(this._router.url);
|
||||
|
||||
filterRoute$ = combineLatest([this.processId$, this.currentUrl$]).pipe(
|
||||
map(([processId, url]) => {
|
||||
const route = this._navigation.filterRoute({ processId, comingFrom: url?.split('?')[0] });
|
||||
const routeTree = this._router.createUrlTree(route.path, { queryParams: route.queryParams });
|
||||
const currentlyActive = this._router.isActive(routeTree, {
|
||||
fragment: 'ignored',
|
||||
matrixParams: 'ignored',
|
||||
paths: 'exact',
|
||||
queryParams: 'ignored',
|
||||
});
|
||||
|
||||
if (currentlyActive) {
|
||||
const urlTree = this._router.parseUrl(url);
|
||||
const comingFrom = urlTree.queryParamMap.get('comingFrom');
|
||||
return { path: [comingFrom], urlTree } as NavigationRoute;
|
||||
}
|
||||
|
||||
return route;
|
||||
}),
|
||||
);
|
||||
|
||||
routerEventsSubscription: Subscription;
|
||||
|
||||
filter$ = this._store.filter$;
|
||||
|
||||
hasFilter$ = this.filter$.pipe(
|
||||
map((filter) => {
|
||||
if (!filter) return false;
|
||||
const qt = filter.getQueryToken();
|
||||
return !isEmpty(qt.filter);
|
||||
}),
|
||||
);
|
||||
|
||||
fetching$ = this._store.fetchingCustomerList$;
|
||||
|
||||
hits$ = this._store.customerListCount$;
|
||||
|
||||
customers$ = this._store.customerList$;
|
||||
|
||||
@ViewChild(CustomerResultListComponent, { static: true }) customerResultListComponent: CustomerResultListComponent;
|
||||
|
||||
isTablet$ = this._environment.matchTablet$;
|
||||
|
||||
isDesktopSmall$ = this._environment.matchDesktopSmall$;
|
||||
|
||||
message$ = this._store.message$;
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.routerEventsSubscription = this._router.events.subscribe((event) => {
|
||||
if (event instanceof NavigationEnd) {
|
||||
this.currentUrl$.next(event.url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routerEventsSubscription?.unsubscribe();
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
}
|
||||
|
||||
ngAfterContentInit(): void {
|
||||
const scrollIndex = this._store.restoreScrollIndex();
|
||||
|
||||
if (typeof scrollIndex === 'number') {
|
||||
const hasCustomerList$ = this._store.customerList$.pipe(filter((customers) => customers?.length > 0));
|
||||
|
||||
race(hasCustomerList$, this._store.customerListRestored$, this._store.customerListResponse$)
|
||||
.pipe(takeUntil(this._onDestroy$), delay(100), take(1))
|
||||
.subscribe(() => {
|
||||
this.customerResultListComponent.scrollToIndex(Number(scrollIndex));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
search(filter: Filter) {
|
||||
this._store.setFilter(filter);
|
||||
this._store.search({ resetScrollIndex: true, ignoreRestore: true });
|
||||
}
|
||||
|
||||
paginate() {
|
||||
this._store.paginate();
|
||||
}
|
||||
|
||||
scrollIndexChange(index: number) {
|
||||
this._store.storeScrollIndex(index);
|
||||
}
|
||||
}
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
AfterContentInit,
|
||||
ViewChild,
|
||||
inject,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { CustomerSearchStore } from '../store/customer-search.store';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Subject,
|
||||
Subscription,
|
||||
combineLatest,
|
||||
race,
|
||||
} from 'rxjs';
|
||||
import { delay, filter, map, take, takeUntil } from 'rxjs/operators';
|
||||
import {
|
||||
CustomerSearchNavigation,
|
||||
NavigationRoute,
|
||||
} from '@shared/services/navigation';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { Filter } from '@shared/components/filter';
|
||||
import { CustomerResultListComponent } from '../../components/customer-result-list/customer-result-list.component';
|
||||
import { EnvironmentService } from '@core/environment';
|
||||
import { injectCancelSearch } from '@shared/services/cancel-subject';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { useQueryParamSubtitle } from '@isa/core/tabs';
|
||||
|
||||
@Component({
|
||||
selector: 'page-customer-results-main-view',
|
||||
templateUrl: 'results-main-view.component.html',
|
||||
styleUrls: ['results-main-view.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class CustomerResultsMainViewComponent
|
||||
implements OnInit, OnDestroy, AfterContentInit
|
||||
{
|
||||
private _store = inject(CustomerSearchStore);
|
||||
private _router = inject(Router);
|
||||
private _navigation = inject(CustomerSearchNavigation);
|
||||
private _environment = inject(EnvironmentService);
|
||||
|
||||
cancelSearch = injectCancelSearch();
|
||||
|
||||
processId$ = this._store.processId$;
|
||||
|
||||
currentUrl$ = new BehaviorSubject<string>(this._router.url);
|
||||
|
||||
filterRoute$ = combineLatest([this.processId$, this.currentUrl$]).pipe(
|
||||
map(([processId, url]) => {
|
||||
const route = this._navigation.filterRoute({
|
||||
processId,
|
||||
comingFrom: url?.split('?')[0],
|
||||
});
|
||||
const routeTree = this._router.createUrlTree(route.path, {
|
||||
queryParams: route.queryParams,
|
||||
});
|
||||
const currentlyActive = this._router.isActive(routeTree, {
|
||||
fragment: 'ignored',
|
||||
matrixParams: 'ignored',
|
||||
paths: 'exact',
|
||||
queryParams: 'ignored',
|
||||
});
|
||||
|
||||
if (currentlyActive) {
|
||||
const urlTree = this._router.parseUrl(url);
|
||||
const comingFrom = urlTree.queryParamMap.get('comingFrom');
|
||||
return { path: [comingFrom], urlTree } as NavigationRoute;
|
||||
}
|
||||
|
||||
return route;
|
||||
}),
|
||||
);
|
||||
|
||||
routerEventsSubscription: Subscription;
|
||||
|
||||
filter$ = this._store.filter$;
|
||||
|
||||
hasFilter$ = this.filter$.pipe(
|
||||
map((filter) => {
|
||||
if (!filter) return false;
|
||||
const qt = filter.getQueryToken();
|
||||
return !isEmpty(qt.filter);
|
||||
}),
|
||||
);
|
||||
|
||||
fetching$ = this._store.fetchingCustomerList$;
|
||||
|
||||
hits$ = this._store.customerListCount$;
|
||||
|
||||
customers$ = this._store.customerList$;
|
||||
|
||||
@ViewChild(CustomerResultListComponent, { static: true })
|
||||
customerResultListComponent: CustomerResultListComponent;
|
||||
|
||||
isTablet$ = this._environment.matchTablet$;
|
||||
|
||||
isDesktopSmall$ = this._environment.matchDesktopSmall$;
|
||||
|
||||
message$ = this._store.message$;
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
processId = toSignal(this.processId$);
|
||||
|
||||
filter = toSignal(this.filter$);
|
||||
|
||||
filterParams = computed(() => this.filter()?.getQueryParams());
|
||||
|
||||
constructor() {
|
||||
useQueryParamSubtitle(this.filterParams, 'main_qs', 'Kundensuche');
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.routerEventsSubscription = this._router.events.subscribe((event) => {
|
||||
if (event instanceof NavigationEnd) {
|
||||
this.currentUrl$.next(event.url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routerEventsSubscription?.unsubscribe();
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
}
|
||||
|
||||
ngAfterContentInit(): void {
|
||||
const scrollIndex = this._store.restoreScrollIndex();
|
||||
|
||||
if (typeof scrollIndex === 'number') {
|
||||
const hasCustomerList$ = this._store.customerList$.pipe(
|
||||
filter((customers) => customers?.length > 0),
|
||||
);
|
||||
|
||||
race(
|
||||
hasCustomerList$,
|
||||
this._store.customerListRestored$,
|
||||
this._store.customerListResponse$,
|
||||
)
|
||||
.pipe(takeUntil(this._onDestroy$), delay(100), take(1))
|
||||
.subscribe(() => {
|
||||
this.customerResultListComponent.scrollToIndex(Number(scrollIndex));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
search(filter: Filter) {
|
||||
this._store.setFilter(filter);
|
||||
this._store.search({ resetScrollIndex: true, ignoreRestore: true });
|
||||
}
|
||||
|
||||
paginate() {
|
||||
this._store.paginate();
|
||||
}
|
||||
|
||||
scrollIndexChange(index: number) {
|
||||
this._store.storeScrollIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,19 +43,19 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'search',
|
||||
component: CustomerMainViewComponent,
|
||||
title: 'Kundensuche',
|
||||
title: 'Kunden',
|
||||
data: { side: 'main', breadcrumb: 'main' },
|
||||
},
|
||||
{
|
||||
path: 'search/list',
|
||||
component: CustomerResultsMainViewComponent,
|
||||
title: 'Kundensuche - Trefferliste',
|
||||
title: 'Kundensuche',
|
||||
data: { breadcrumb: 'search' },
|
||||
},
|
||||
{
|
||||
path: 'search/filter',
|
||||
component: CustomerFilterMainViewComponent,
|
||||
title: 'Kundensuche - Filter',
|
||||
title: 'Kundensuche',
|
||||
data: { side: 'results', breadcrumb: 'filter' },
|
||||
},
|
||||
{
|
||||
@@ -161,9 +161,13 @@ export const routes: Routes = [
|
||||
component: CreateCustomerComponent,
|
||||
canActivate: [CustomerCreateGuard],
|
||||
canActivateChild: [CustomerCreateGuard],
|
||||
title: 'Kundendaten erfassen',
|
||||
title: 'Kunden erfassen',
|
||||
children: [
|
||||
{ path: 'create', component: CreateStoreCustomerComponent },
|
||||
{
|
||||
path: 'create',
|
||||
component: CreateStoreCustomerComponent,
|
||||
title: 'Kunden',
|
||||
},
|
||||
{ path: 'create/store', component: CreateStoreCustomerComponent },
|
||||
{ path: 'create/webshop', component: CreateWebshopCustomerComponent },
|
||||
{ path: 'create/b2b', component: CreateB2BCustomerComponent },
|
||||
|
||||
@@ -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:**
|
||||
- `id: number` - Tab ID to query
|
||||
@@ -473,12 +473,13 @@ Gets current location with automatic index validation.
|
||||
**Returns:** Current location, or null if none or invalid
|
||||
|
||||
**Side Effects:**
|
||||
- Automatically corrects invalid history indices
|
||||
- Logs warnings if index correction occurs (when enabled)
|
||||
- Automatically corrects invalid history indices (when `config.enableIndexValidation` is true)
|
||||
- Logs warnings if index correction occurs
|
||||
- Triggers storage autosave when corrections are made
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const currentLoc = this.#tabService.getCurrentLocation(42);
|
||||
const currentLoc = this.#tabService.getCurrentLocationWithValidation(42);
|
||||
if (currentLoc) {
|
||||
console.log(`Current page: ${currentLoc.title}`);
|
||||
}
|
||||
@@ -595,6 +596,52 @@ export class MyComponent {
|
||||
}
|
||||
```
|
||||
|
||||
#### `useTabSubtitle<T>(source, transform, fallback?): void`
|
||||
|
||||
Sets up reactive tab subtitle management. Creates an effect that automatically updates the active tab's subtitle when the source signal changes.
|
||||
|
||||
**Parameters:**
|
||||
- `source: Signal<T>` - Signal containing the data to derive subtitle from
|
||||
- `transform: (value: T) => string | undefined | null` - Function to extract subtitle
|
||||
- `fallback?: string` - Optional fallback when transform returns undefined/null
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
export class CustomerDetailsComponent {
|
||||
customerSignal = toSignal(this._store.customer$);
|
||||
|
||||
constructor() {
|
||||
// Automatically updates tab subtitle when customer changes
|
||||
useTabSubtitle(
|
||||
this.customerSignal,
|
||||
customer => getCustomerName(customer),
|
||||
'Kundendetails'
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `useQueryParamSubtitle(queryParamsSource, key?, fallback?): void`
|
||||
|
||||
Specialized helper for search components. Extracts subtitle from a query params object.
|
||||
|
||||
**Parameters:**
|
||||
- `queryParamsSource: Signal<Record<string, string> | undefined | null>` - Signal with query params
|
||||
- `key?: string` - Key to extract (default: 'main_qs')
|
||||
- `fallback?: string` - Fallback string (default: 'Suche')
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
export class CustomerSearchComponent {
|
||||
filter = toSignal(this._store.filter$);
|
||||
filterParams = computed(() => this.filter()?.getQueryParams());
|
||||
|
||||
constructor() {
|
||||
useQueryParamSubtitle(this.filterParams, 'main_qs', 'Kundensuche');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `getTabHelper(tabId: number, entities: Record<number, Tab>): Tab | undefined`
|
||||
|
||||
Retrieves a tab from entity map.
|
||||
@@ -1502,7 +1549,7 @@ withStorage('tabs', UserStorageProvider, { autosave: true })
|
||||
|
||||
The system maintains history index integrity through:
|
||||
|
||||
1. **Validation on read** - `getCurrentLocation()` validates indices
|
||||
1. **Validation on read** - `getCurrentLocationWithValidation()` validates indices
|
||||
2. **Validation on navigation** - Back/forward check bounds
|
||||
3. **Correction on errors** - Invalid indices auto-corrected
|
||||
4. **Logging** - Optional warnings for debugging
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
export * from './lib/navigate-back-button.component';
|
||||
export * from './lib/tab.injector';
|
||||
export * from './lib/tab.resolver-fn';
|
||||
export * from './lib/schemas';
|
||||
export * from './lib/tab';
|
||||
export * from './lib/tab-navigation.service';
|
||||
export * from './lib/tab-navigation.constants';
|
||||
export * from './lib/tab-config';
|
||||
export * from './lib/components';
|
||||
export * from './lib/guards';
|
||||
export * from './lib/resolvers';
|
||||
export * from './lib/provider';
|
||||
export * from './lib/helpers';
|
||||
export * from './lib/has-tab-id.guard';
|
||||
export * from './lib/tab-cleanup.guard';
|
||||
export * from './lib/deactivate-tab.guard';
|
||||
export * from './lib/tab.service';
|
||||
export * from './lib/tab-config';
|
||||
export * from './lib/schemas';
|
||||
export * from './lib/tab.injector';
|
||||
|
||||
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 { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionChevronLeft } from '@isa/icons';
|
||||
import { TabService } from './tab';
|
||||
import { TabService } from '../tab.service';
|
||||
import { ButtonComponent } from '@isa/ui/buttons';
|
||||
|
||||
@Component({
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Constants for tab navigation behavior and URL filtering.
|
||||
*
|
||||
* This module provides configuration constants that control how URLs are
|
||||
* handled in the tab navigation history system.
|
||||
*/
|
||||
import { inject, InjectionToken } from '@angular/core';
|
||||
import { Config } from '@core/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* URL patterns that should not be added to tab navigation history.
|
||||
@@ -24,3 +21,11 @@
|
||||
* ```
|
||||
*/
|
||||
export const HISTORY_BLACKLIST_PATTERNS = ['/kunde/dashboard', '/dashboard'];
|
||||
|
||||
export const TITLE_PREFIX = new InjectionToken('isa.core.tabs.title-prefix', {
|
||||
providedIn: 'root',
|
||||
factory: () => {
|
||||
const config = inject(Config);
|
||||
return config.get('title', z.string().default('ISA'));
|
||||
},
|
||||
});
|
||||
@@ -1,35 +1,35 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn } from '@angular/router';
|
||||
import { TabService } from './tab';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
/**
|
||||
* Guard that deactivates the currently active tab when entering a route.
|
||||
*
|
||||
* Use this guard on routes that exist outside of a process/tab context,
|
||||
* such as dashboard pages or global branch operations.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const routes: Routes = [
|
||||
* {
|
||||
* path: 'dashboard',
|
||||
* loadChildren: () => import('./dashboard').then(m => m.DashboardModule),
|
||||
* canActivate: [deactivateTabGuard],
|
||||
* },
|
||||
* ];
|
||||
* ```
|
||||
*/
|
||||
export const deactivateTabGuard: CanActivateFn = () => {
|
||||
const tabService = inject(TabService);
|
||||
const log = logger({ guard: 'deactivateTabGuard' });
|
||||
|
||||
const previousTabId = tabService.activatedTabId();
|
||||
|
||||
if (previousTabId !== null) {
|
||||
tabService.deactivateTab();
|
||||
log.debug('Tab deactivated on route activation', () => ({ previousTabId }));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn } from '@angular/router';
|
||||
import { TabService } from '../tab.service';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
/**
|
||||
* Guard that deactivates the currently active tab when entering a route.
|
||||
*
|
||||
* Use this guard on routes that exist outside of a process/tab context,
|
||||
* such as dashboard pages or global branch operations.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const routes: Routes = [
|
||||
* {
|
||||
* path: 'dashboard',
|
||||
* loadChildren: () => import('./dashboard').then(m => m.DashboardModule),
|
||||
* canActivate: [deactivateTabGuard],
|
||||
* },
|
||||
* ];
|
||||
* ```
|
||||
*/
|
||||
export const deactivateTabGuard: CanActivateFn = () => {
|
||||
const tabService = inject(TabService);
|
||||
const log = logger({ guard: 'deactivateTabGuard' });
|
||||
|
||||
const previousTabId = tabService.activatedTabId();
|
||||
|
||||
if (previousTabId !== null) {
|
||||
tabService.deactivateTab();
|
||||
log.debug('Tab deactivated on route activation', () => ({ previousTabId }));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
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,195 +1,195 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanDeactivateFn, Router } from '@angular/router';
|
||||
import { TabService } from './tab';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import {
|
||||
CheckoutMetadataService,
|
||||
ShoppingCartService,
|
||||
} from '@isa/checkout/data-access';
|
||||
import {
|
||||
getNextTabNameHelper,
|
||||
formatCustomerTabNameHelper,
|
||||
checkCartHasItemsHelper,
|
||||
} from './helpers';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { CrmTabMetadataService } from '@isa/crm/data-access';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
// TODO: #5484 Move Guard to other location + Use resources for fetching cart data
|
||||
/**
|
||||
* CanDeactivate Guard that manages tab context based on shopping cart state.
|
||||
*
|
||||
* This guard checks both the regular shopping cart and reward shopping cart:
|
||||
* - If BOTH carts are empty (or don't exist), the tab context is cleared and renamed to "Vorgang X"
|
||||
* - If EITHER cart still has items:
|
||||
* - Customer context is preserved
|
||||
* - Tab name is updated to show customer name (or organization name for B2B)
|
||||
* - process_type is set to 'cart-checkout' to show cart icon
|
||||
*
|
||||
* Usage: Apply to checkout-summary routes to automatically manage tab state after order completion.
|
||||
*/
|
||||
export const canDeactivateTabCleanup: CanDeactivateFn<unknown> = async () => {
|
||||
const tabService = inject(TabService);
|
||||
const checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
const crmTabMetadataService = inject(CrmTabMetadataService);
|
||||
const shoppingCartService = inject(ShoppingCartService);
|
||||
const domainCheckoutService = inject(DomainCheckoutService);
|
||||
const router = inject(Router);
|
||||
const log = logger(() => ({ guard: 'TabCleanup' }));
|
||||
|
||||
const tabId = tabService.activatedTabId();
|
||||
if (!tabId) {
|
||||
log.warn('No active tab found');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the target URL contains a tab ID and if it matches the current tab
|
||||
// Routes without tab ID (e.g., /filiale/package-inspection, /kunde/dashboard) are global areas
|
||||
// Routes with different tab ID (e.g., creating new process) should not affect current tab
|
||||
const nextUrl = router.getCurrentNavigation()?.finalUrl?.toString() ?? '';
|
||||
const tabIdMatch = nextUrl.match(/\/(\d{10,})\//);
|
||||
const targetTabId = tabIdMatch ? parseInt(tabIdMatch[1], 10) : null;
|
||||
|
||||
// Skip cleanup if navigating to global area or different tab
|
||||
if (!targetTabId || targetTabId !== tabId) {
|
||||
log.debug(
|
||||
targetTabId
|
||||
? 'Navigating to different tab, keeping current tab unchanged'
|
||||
: 'Navigating to global area (no tab ID), keeping tab unchanged',
|
||||
() => ({
|
||||
currentTabId: tabId,
|
||||
targetTabId,
|
||||
nextUrl,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get shopping cart IDs from tab metadata
|
||||
const shoppingCartId = checkoutMetadataService.getShoppingCartId(tabId);
|
||||
const rewardShoppingCartId =
|
||||
checkoutMetadataService.getRewardShoppingCartId(tabId);
|
||||
|
||||
// Load carts and check if they have items
|
||||
let regularCart = null;
|
||||
if (shoppingCartId) {
|
||||
try {
|
||||
regularCart = await shoppingCartService.getShoppingCart(shoppingCartId);
|
||||
} catch (error) {
|
||||
log.debug('Could not load regular shopping cart', () => ({
|
||||
shoppingCartId,
|
||||
error: (error as Error).message,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let rewardCart = null;
|
||||
if (rewardShoppingCartId) {
|
||||
try {
|
||||
rewardCart =
|
||||
await shoppingCartService.getShoppingCart(rewardShoppingCartId);
|
||||
} catch (error) {
|
||||
log.debug('Could not load reward shopping cart', () => ({
|
||||
rewardShoppingCartId,
|
||||
error: (error as Error).message,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const hasRegularItems = checkCartHasItemsHelper(regularCart);
|
||||
const hasRewardItems = checkCartHasItemsHelper(rewardCart);
|
||||
|
||||
log.debug('Cart status check', () => ({
|
||||
tabId,
|
||||
shoppingCartId,
|
||||
rewardShoppingCartId,
|
||||
hasRegularItems,
|
||||
hasRewardItems,
|
||||
}));
|
||||
|
||||
// If either cart has items, preserve context and update tab name with customer info
|
||||
if (hasRegularItems || hasRewardItems) {
|
||||
log.info(
|
||||
'Preserving checkout context - cart(s) still have items',
|
||||
() => ({
|
||||
tabId,
|
||||
hasRegularItems,
|
||||
hasRewardItems,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
// Get customer from checkout service
|
||||
const customer = await firstValueFrom(
|
||||
domainCheckoutService.getCustomer({ processId: tabId }),
|
||||
);
|
||||
|
||||
if (customer) {
|
||||
const name = formatCustomerTabNameHelper(customer);
|
||||
|
||||
if (name) {
|
||||
// Update tab name with customer info
|
||||
tabService.patchTab(tabId, { name });
|
||||
|
||||
// Ensure process_type is 'cart' for proper cart icon display
|
||||
tabService.patchTabMetadata(tabId, {
|
||||
process_type: 'cart',
|
||||
});
|
||||
|
||||
log.info('Updated tab name with customer info', () => ({
|
||||
tabId,
|
||||
customerName: name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If customer data can't be loaded, just log and continue
|
||||
log.warn('Could not load customer for tab name update', () => ({
|
||||
tabId,
|
||||
error: (error as Error).message,
|
||||
}));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Both carts are empty - clean up context
|
||||
log.info('Cleaning up checkout context - both carts empty', () => ({
|
||||
tabId,
|
||||
}));
|
||||
|
||||
// Remove checkout state from store (customer, buyer, payer, etc.)
|
||||
domainCheckoutService.removeProcess({ processId: tabId });
|
||||
|
||||
// Clear customer-related metadata (prevents old customer data from being reused)
|
||||
crmTabMetadataService.setSelectedCustomerId(tabId, undefined);
|
||||
crmTabMetadataService.setSelectedPayerId(tabId, undefined);
|
||||
crmTabMetadataService.setSelectedShippingAddressId(tabId, undefined);
|
||||
|
||||
// Create new shopping cart and update Store (this automatically dispatches setShoppingCart action)
|
||||
await firstValueFrom(
|
||||
domainCheckoutService.createShoppingCart({ processId: tabId }),
|
||||
);
|
||||
|
||||
// Clear tab metadata and location history, but keep process_type for cart icon
|
||||
tabService.patchTabMetadata(tabId, { process_type: 'cart' });
|
||||
tabService.clearLocationHistory(tabId);
|
||||
|
||||
// Rename tab to next "Vorgang X" based on count of existing Vorgang tabs
|
||||
const tabName = getNextTabNameHelper(tabService.entityMap());
|
||||
tabService.patchTab(tabId, { name: tabName });
|
||||
|
||||
log.info('Tab reset to clean state', () => ({
|
||||
tabId,
|
||||
name: tabName,
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error('Error in checkout cleanup guard', error as Error, () => ({
|
||||
tabId,
|
||||
}));
|
||||
return true; // Allow navigation even if cleanup fails
|
||||
}
|
||||
};
|
||||
import { inject } from '@angular/core';
|
||||
import { CanDeactivateFn, Router } from '@angular/router';
|
||||
import { TabService } from '../tab.service';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import {
|
||||
CheckoutMetadataService,
|
||||
ShoppingCartService,
|
||||
} from '@isa/checkout/data-access';
|
||||
import {
|
||||
getNextTabNameHelper,
|
||||
formatCustomerTabNameHelper,
|
||||
checkCartHasItemsHelper,
|
||||
} from '../helpers';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { CrmTabMetadataService } from '@isa/crm/data-access';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
// TODO: #5484 Move Guard to other location + Use resources for fetching cart data
|
||||
/**
|
||||
* CanDeactivate Guard that manages tab context based on shopping cart state.
|
||||
*
|
||||
* This guard checks both the regular shopping cart and reward shopping cart:
|
||||
* - If BOTH carts are empty (or don't exist), the tab context is cleared and renamed to "Vorgang X"
|
||||
* - If EITHER cart still has items:
|
||||
* - Customer context is preserved
|
||||
* - Tab name is updated to show customer name (or organization name for B2B)
|
||||
* - process_type is set to 'cart-checkout' to show cart icon
|
||||
*
|
||||
* Usage: Apply to checkout-summary routes to automatically manage tab state after order completion.
|
||||
*/
|
||||
export const canDeactivateTabCleanup: CanDeactivateFn<unknown> = async () => {
|
||||
const tabService = inject(TabService);
|
||||
const checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
const crmTabMetadataService = inject(CrmTabMetadataService);
|
||||
const shoppingCartService = inject(ShoppingCartService);
|
||||
const domainCheckoutService = inject(DomainCheckoutService);
|
||||
const router = inject(Router);
|
||||
const log = logger(() => ({ guard: 'TabCleanup' }));
|
||||
|
||||
const tabId = tabService.activatedTabId();
|
||||
if (!tabId) {
|
||||
log.warn('No active tab found');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the target URL contains a tab ID and if it matches the current tab
|
||||
// Routes without tab ID (e.g., /filiale/package-inspection, /kunde/dashboard) are global areas
|
||||
// Routes with different tab ID (e.g., creating new process) should not affect current tab
|
||||
const nextUrl = router.getCurrentNavigation()?.finalUrl?.toString() ?? '';
|
||||
const tabIdMatch = nextUrl.match(/\/(\d{10,})\//);
|
||||
const targetTabId = tabIdMatch ? parseInt(tabIdMatch[1], 10) : null;
|
||||
|
||||
// Skip cleanup if navigating to global area or different tab
|
||||
if (!targetTabId || targetTabId !== tabId) {
|
||||
log.debug(
|
||||
targetTabId
|
||||
? 'Navigating to different tab, keeping current tab unchanged'
|
||||
: 'Navigating to global area (no tab ID), keeping tab unchanged',
|
||||
() => ({
|
||||
currentTabId: tabId,
|
||||
targetTabId,
|
||||
nextUrl,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get shopping cart IDs from tab metadata
|
||||
const shoppingCartId = checkoutMetadataService.getShoppingCartId(tabId);
|
||||
const rewardShoppingCartId =
|
||||
checkoutMetadataService.getRewardShoppingCartId(tabId);
|
||||
|
||||
// Load carts and check if they have items
|
||||
let regularCart = null;
|
||||
if (shoppingCartId) {
|
||||
try {
|
||||
regularCart = await shoppingCartService.getShoppingCart(shoppingCartId);
|
||||
} catch (error) {
|
||||
log.debug('Could not load regular shopping cart', () => ({
|
||||
shoppingCartId,
|
||||
error: (error as Error).message,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let rewardCart = null;
|
||||
if (rewardShoppingCartId) {
|
||||
try {
|
||||
rewardCart =
|
||||
await shoppingCartService.getShoppingCart(rewardShoppingCartId);
|
||||
} catch (error) {
|
||||
log.debug('Could not load reward shopping cart', () => ({
|
||||
rewardShoppingCartId,
|
||||
error: (error as Error).message,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const hasRegularItems = checkCartHasItemsHelper(regularCart);
|
||||
const hasRewardItems = checkCartHasItemsHelper(rewardCart);
|
||||
|
||||
log.debug('Cart status check', () => ({
|
||||
tabId,
|
||||
shoppingCartId,
|
||||
rewardShoppingCartId,
|
||||
hasRegularItems,
|
||||
hasRewardItems,
|
||||
}));
|
||||
|
||||
// If either cart has items, preserve context and update tab name with customer info
|
||||
if (hasRegularItems || hasRewardItems) {
|
||||
log.info(
|
||||
'Preserving checkout context - cart(s) still have items',
|
||||
() => ({
|
||||
tabId,
|
||||
hasRegularItems,
|
||||
hasRewardItems,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
// Get customer from checkout service
|
||||
const customer = await firstValueFrom(
|
||||
domainCheckoutService.getCustomer({ processId: tabId }),
|
||||
);
|
||||
|
||||
if (customer) {
|
||||
const name = formatCustomerTabNameHelper(customer);
|
||||
|
||||
if (name) {
|
||||
// Update tab name with customer info
|
||||
tabService.patchTab(tabId, { name });
|
||||
|
||||
// Ensure process_type is 'cart' for proper cart icon display
|
||||
tabService.patchTabMetadata(tabId, {
|
||||
process_type: 'cart',
|
||||
});
|
||||
|
||||
log.info('Updated tab name with customer info', () => ({
|
||||
tabId,
|
||||
customerName: name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If customer data can't be loaded, just log and continue
|
||||
log.warn('Could not load customer for tab name update', () => ({
|
||||
tabId,
|
||||
error: (error as Error).message,
|
||||
}));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Both carts are empty - clean up context
|
||||
log.info('Cleaning up checkout context - both carts empty', () => ({
|
||||
tabId,
|
||||
}));
|
||||
|
||||
// Remove checkout state from store (customer, buyer, payer, etc.)
|
||||
domainCheckoutService.removeProcess({ processId: tabId });
|
||||
|
||||
// Clear customer-related metadata (prevents old customer data from being reused)
|
||||
crmTabMetadataService.setSelectedCustomerId(tabId, undefined);
|
||||
crmTabMetadataService.setSelectedPayerId(tabId, undefined);
|
||||
crmTabMetadataService.setSelectedShippingAddressId(tabId, undefined);
|
||||
|
||||
// Create new shopping cart and update Store (this automatically dispatches setShoppingCart action)
|
||||
await firstValueFrom(
|
||||
domainCheckoutService.createShoppingCart({ processId: tabId }),
|
||||
);
|
||||
|
||||
// Clear tab metadata and location history, but keep process_type for cart icon
|
||||
tabService.patchTabMetadata(tabId, { process_type: 'cart' });
|
||||
tabService.clearLocationHistory(tabId);
|
||||
|
||||
// Rename tab to next "Vorgang X" based on count of existing Vorgang tabs
|
||||
const tabName = getNextTabNameHelper(tabService.entityMap());
|
||||
tabService.patchTab(tabId, { name: tabName });
|
||||
|
||||
log.info('Tab reset to clean state', () => ({
|
||||
tabId,
|
||||
name: tabName,
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error('Error in checkout cleanup guard', error as Error, () => ({
|
||||
tabId,
|
||||
}));
|
||||
return true; // Allow navigation even if cleanup fails
|
||||
}
|
||||
};
|
||||
@@ -1,341 +1,346 @@
|
||||
/**
|
||||
* @fileoverview Tab history pruning utilities with multiple strategies for managing navigation history size.
|
||||
*
|
||||
* This module provides sophisticated history management for tab navigation:
|
||||
* - Three pruning strategies: oldest, balanced, and smart
|
||||
* - Forward history limiting when adding new locations
|
||||
* - Index validation and correction utilities
|
||||
* - Configurable pruning behavior per tab or globally
|
||||
*
|
||||
* The pruning system prevents unlimited memory growth while preserving
|
||||
* navigation functionality and user experience. Each strategy offers
|
||||
* different trade-offs between memory usage and history preservation.
|
||||
*/
|
||||
|
||||
import { TabLocation, TabLocationHistory } from './schemas';
|
||||
import { TabConfig } from './tab-config';
|
||||
|
||||
/**
|
||||
* Result of a history pruning operation.
|
||||
*
|
||||
* Contains the pruned location array, updated current index,
|
||||
* and metadata about the pruning operation performed.
|
||||
*/
|
||||
export interface HistoryPruningResult {
|
||||
/** Array of locations after pruning */
|
||||
locations: TabLocation[];
|
||||
/** Updated current index after pruning */
|
||||
newCurrent: number;
|
||||
/** Number of entries removed during pruning */
|
||||
entriesRemoved: number;
|
||||
/** Name of the pruning strategy used */
|
||||
strategy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static utility class for managing tab navigation history pruning.
|
||||
*
|
||||
* Provides multiple strategies for reducing history size while maintaining
|
||||
* navigation functionality. All methods are static and stateless.
|
||||
*/
|
||||
export class TabHistoryPruner {
|
||||
|
||||
/**
|
||||
* Prunes history based on the configured strategy.
|
||||
*
|
||||
* Automatically selects and applies the appropriate pruning strategy
|
||||
* based on configuration. Supports per-tab metadata overrides.
|
||||
*
|
||||
* @param locationHistory - Current tab location history to prune
|
||||
* @param config - Global tab configuration
|
||||
* @param tabMetadata - Optional per-tab configuration overrides
|
||||
* @returns Pruning result with updated locations and metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = TabHistoryPruner.pruneHistory(
|
||||
* tab.location,
|
||||
* globalConfig,
|
||||
* { maxHistorySize: 25 }
|
||||
* );
|
||||
* if (result.entriesRemoved > 0) {
|
||||
* console.log(`Pruned ${result.entriesRemoved} entries using ${result.strategy}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static pruneHistory(
|
||||
locationHistory: TabLocationHistory,
|
||||
config: TabConfig,
|
||||
tabMetadata?: { maxHistorySize?: number; maxForwardHistory?: number }
|
||||
): HistoryPruningResult {
|
||||
const maxSize = tabMetadata?.maxHistorySize ?? config.maxHistorySize;
|
||||
const { locations, current } = locationHistory;
|
||||
|
||||
if (locations.length <= maxSize) {
|
||||
return {
|
||||
locations: [...locations],
|
||||
newCurrent: current,
|
||||
entriesRemoved: 0,
|
||||
strategy: 'no-pruning'
|
||||
};
|
||||
}
|
||||
|
||||
const strategy = config.pruningStrategy;
|
||||
|
||||
switch (strategy) {
|
||||
case 'oldest':
|
||||
return this.pruneOldestFirst(locations, current, maxSize);
|
||||
case 'balanced':
|
||||
return this.pruneBalanced(locations, current, maxSize);
|
||||
case 'smart':
|
||||
return this.pruneSmart(locations, current, maxSize, config);
|
||||
default:
|
||||
return this.pruneBalanced(locations, current, maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes oldest entries first, adjusting current index.
|
||||
*
|
||||
* Simple FIFO (First In, First Out) pruning strategy that removes
|
||||
* the oldest history entries when the size limit is exceeded.
|
||||
* Preserves recent navigation while maintaining current position.
|
||||
*
|
||||
* @param locations - Array of tab locations to prune
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxSize - Maximum number of locations to keep
|
||||
* @returns Pruning result with updated locations and index
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With locations [A, B, C, D, E] and current=2 (C), maxSize=3
|
||||
* // Result: [C, D, E] with newCurrent=0 (still pointing to C)
|
||||
* ```
|
||||
*/
|
||||
private static pruneOldestFirst(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxSize: number
|
||||
): HistoryPruningResult {
|
||||
const removeCount = locations.length - maxSize;
|
||||
const prunedLocations = locations.slice(removeCount);
|
||||
const newCurrent = Math.max(-1, current - removeCount);
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent,
|
||||
entriesRemoved: removeCount,
|
||||
strategy: 'oldest-first'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps entries balanced around the current position.
|
||||
*
|
||||
* Maintains a balanced window around the current location, preserving
|
||||
* 70% of entries before current and 30% after. This strategy provides
|
||||
* good back/forward navigation while respecting size limits.
|
||||
*
|
||||
* @param locations - Array of tab locations to prune
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxSize - Maximum number of locations to keep
|
||||
* @returns Pruning result with maintained current position
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With current=5 in 20 locations, maxSize=10
|
||||
* // Keeps ~7 entries before current, current entry, ~2 entries after
|
||||
* // Result preserves navigation context around current position
|
||||
* ```
|
||||
*/
|
||||
private static pruneBalanced(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxSize: number
|
||||
): HistoryPruningResult {
|
||||
// Preserve 70% of entries before current, 30% after
|
||||
const backwardRatio = 0.7;
|
||||
const maxBackward = Math.floor(maxSize * backwardRatio);
|
||||
const maxForward = maxSize - maxBackward - 1; // -1 for current item
|
||||
|
||||
const keepStart = Math.max(0, current - maxBackward);
|
||||
const keepEnd = Math.min(locations.length, current + 1 + maxForward);
|
||||
|
||||
const prunedLocations = locations.slice(keepStart, keepEnd);
|
||||
const newCurrent = current - keepStart;
|
||||
const entriesRemoved = locations.length - prunedLocations.length;
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent,
|
||||
entriesRemoved,
|
||||
strategy: 'balanced'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligent pruning based on usage patterns and recency.
|
||||
*
|
||||
* Uses a scoring algorithm that considers both recency (how recently
|
||||
* a location was visited) and proximity (how close to current position).
|
||||
* Recent locations and those near the current position get higher scores
|
||||
* and are more likely to be preserved.
|
||||
*
|
||||
* Scoring factors:
|
||||
* - Recent (< 1 hour): 100 points base
|
||||
* - Medium (< 1 day): 60 points base
|
||||
* - Old (> 1 day): 20 points base
|
||||
* - Proximity: 100 - (distance_from_current * 10) points
|
||||
*
|
||||
* @param locations - Array of tab locations to prune
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxSize - Maximum number of locations to keep
|
||||
* @param config - Tab configuration (unused but kept for consistency)
|
||||
* @returns Pruning result with intelligently selected locations
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Preserves recently visited pages and those near current position
|
||||
* // while removing old, distant entries first
|
||||
* ```
|
||||
*/
|
||||
private static pruneSmart(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxSize: number,
|
||||
config: TabConfig
|
||||
): HistoryPruningResult {
|
||||
const now = Date.now();
|
||||
const oneHour = 60 * 60 * 1000;
|
||||
const oneDay = 24 * oneHour;
|
||||
|
||||
// Score each location based on recency and distance from current
|
||||
const scoredLocations = locations.map((location, index) => {
|
||||
const age = now - location.timestamp;
|
||||
const distanceFromCurrent = Math.abs(index - current);
|
||||
|
||||
// Recent locations get higher scores
|
||||
let recencyScore = 100;
|
||||
if (age > oneDay) recencyScore = 20;
|
||||
else if (age > oneHour) recencyScore = 60;
|
||||
|
||||
// Locations near current position get higher scores
|
||||
const proximityScore = Math.max(0, 100 - (distanceFromCurrent * 10));
|
||||
|
||||
return {
|
||||
location,
|
||||
index,
|
||||
score: recencyScore + proximityScore,
|
||||
isCurrent: index === current
|
||||
};
|
||||
});
|
||||
|
||||
// Always keep current location and sort others by score
|
||||
const currentItem = scoredLocations.find(item => item.isCurrent);
|
||||
const otherItems = scoredLocations
|
||||
.filter(item => !item.isCurrent)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Take top scoring items
|
||||
const itemsToKeep = Math.min(maxSize - 1, otherItems.length); // -1 for current
|
||||
const keptItems = otherItems.slice(0, itemsToKeep);
|
||||
|
||||
if (currentItem) {
|
||||
keptItems.push(currentItem);
|
||||
}
|
||||
|
||||
// Sort by original index to maintain order
|
||||
keptItems.sort((a, b) => a.index - b.index);
|
||||
|
||||
const prunedLocations = keptItems.map(item => item.location);
|
||||
const newCurrent = keptItems.findIndex(item => item.isCurrent);
|
||||
const entriesRemoved = locations.length - prunedLocations.length;
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent: newCurrent === -1 ? Math.max(0, prunedLocations.length - 1) : newCurrent,
|
||||
entriesRemoved,
|
||||
strategy: 'smart'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prunes forward history when adding a new location.
|
||||
*
|
||||
* Limits the number of forward history entries that are preserved
|
||||
* when navigating to a new location. This prevents unlimited
|
||||
* forward history accumulation while maintaining reasonable redo depth.
|
||||
*
|
||||
* @param locations - Current array of tab locations
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxForwardHistory - Maximum forward entries to preserve
|
||||
* @returns Object with pruned locations and unchanged current index
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With current=2, maxForwardHistory=2
|
||||
* // [A, B, C, D, E, F] becomes [A, B, C, D, E]
|
||||
* // Preserves current position while limiting forward entries
|
||||
* ```
|
||||
*/
|
||||
static pruneForwardHistory(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxForwardHistory: number
|
||||
): { locations: TabLocation[]; newCurrent: number } {
|
||||
if (current < 0 || current >= locations.length) {
|
||||
return { locations: [...locations], newCurrent: current };
|
||||
}
|
||||
|
||||
const beforeCurrent = locations.slice(0, current + 1);
|
||||
const afterCurrent = locations.slice(current + 1);
|
||||
|
||||
const limitedAfter = afterCurrent.slice(0, maxForwardHistory);
|
||||
const prunedLocations = [...beforeCurrent, ...limitedAfter];
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent: current // Current position unchanged
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and corrects location history index.
|
||||
*
|
||||
* Ensures the current index is within valid bounds for the locations array.
|
||||
* Corrects invalid indices to the nearest valid value and reports whether
|
||||
* correction was needed. Essential for maintaining data integrity after
|
||||
* history modifications.
|
||||
*
|
||||
* @param locations - Array of tab locations to validate against
|
||||
* @param current - Current index to validate
|
||||
* @returns Object with corrected index and validation status
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { index, wasInvalid } = TabHistoryPruner.validateLocationIndex(
|
||||
* locations,
|
||||
* currentIndex
|
||||
* );
|
||||
* if (wasInvalid) {
|
||||
* console.warn(`Invalid index ${currentIndex} corrected to ${index}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static validateLocationIndex(
|
||||
locations: TabLocation[],
|
||||
current: number
|
||||
): { index: number; wasInvalid: boolean } {
|
||||
if (locations.length === 0) {
|
||||
return { index: -1, wasInvalid: current !== -1 };
|
||||
}
|
||||
|
||||
if (current < -1 || current >= locations.length) {
|
||||
// Invalid index, correct to last valid position
|
||||
const correctedIndex = Math.max(-1, Math.min(locations.length - 1, current));
|
||||
return { index: correctedIndex, wasInvalid: true };
|
||||
}
|
||||
|
||||
return { index: current, wasInvalid: false };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @fileoverview Tab history pruning utilities with multiple strategies for managing navigation history size.
|
||||
*
|
||||
* This module provides sophisticated history management for tab navigation:
|
||||
* - Three pruning strategies: oldest, balanced, and smart
|
||||
* - Forward history limiting when adding new locations
|
||||
* - Index validation and correction utilities
|
||||
* - Configurable pruning behavior per tab or globally
|
||||
*
|
||||
* The pruning system prevents unlimited memory growth while preserving
|
||||
* navigation functionality and user experience. Each strategy offers
|
||||
* different trade-offs between memory usage and history preservation.
|
||||
*/
|
||||
|
||||
import { TabLocation, TabLocationHistory } from '../schemas';
|
||||
import { TabConfig } from '../tab-config';
|
||||
|
||||
/**
|
||||
* Result of a history pruning operation.
|
||||
*
|
||||
* Contains the pruned location array, updated current index,
|
||||
* and metadata about the pruning operation performed.
|
||||
*/
|
||||
export interface HistoryPruningResult {
|
||||
/** Array of locations after pruning */
|
||||
locations: TabLocation[];
|
||||
/** Updated current index after pruning */
|
||||
newCurrent: number;
|
||||
/** Number of entries removed during pruning */
|
||||
entriesRemoved: number;
|
||||
/** Name of the pruning strategy used */
|
||||
strategy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static utility class for managing tab navigation history pruning.
|
||||
*
|
||||
* Provides multiple strategies for reducing history size while maintaining
|
||||
* navigation functionality. All methods are static and stateless.
|
||||
*/
|
||||
export class TabHistoryPruner {
|
||||
/**
|
||||
* Prunes history based on the configured strategy.
|
||||
*
|
||||
* Automatically selects and applies the appropriate pruning strategy
|
||||
* based on configuration. Supports per-tab metadata overrides.
|
||||
*
|
||||
* @param locationHistory - Current tab location history to prune
|
||||
* @param config - Global tab configuration
|
||||
* @param tabMetadata - Optional per-tab configuration overrides
|
||||
* @returns Pruning result with updated locations and metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = TabHistoryPruner.pruneHistory(
|
||||
* tab.location,
|
||||
* globalConfig,
|
||||
* { maxHistorySize: 25 }
|
||||
* );
|
||||
* if (result.entriesRemoved > 0) {
|
||||
* console.log(`Pruned ${result.entriesRemoved} entries using ${result.strategy}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static pruneHistory(
|
||||
locationHistory: TabLocationHistory,
|
||||
config: TabConfig,
|
||||
tabMetadata?: { maxHistorySize?: number; maxForwardHistory?: number },
|
||||
): HistoryPruningResult {
|
||||
const maxSize = tabMetadata?.maxHistorySize ?? config.maxHistorySize;
|
||||
const { locations, current } = locationHistory;
|
||||
|
||||
if (locations.length <= maxSize) {
|
||||
return {
|
||||
locations: [...locations],
|
||||
newCurrent: current,
|
||||
entriesRemoved: 0,
|
||||
strategy: 'no-pruning',
|
||||
};
|
||||
}
|
||||
|
||||
const strategy = config.pruningStrategy;
|
||||
|
||||
switch (strategy) {
|
||||
case 'oldest':
|
||||
return this.pruneOldestFirst(locations, current, maxSize);
|
||||
case 'balanced':
|
||||
return this.pruneBalanced(locations, current, maxSize);
|
||||
case 'smart':
|
||||
return this.pruneSmart(locations, current, maxSize, config);
|
||||
default:
|
||||
return this.pruneBalanced(locations, current, maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes oldest entries first, adjusting current index.
|
||||
*
|
||||
* Simple FIFO (First In, First Out) pruning strategy that removes
|
||||
* the oldest history entries when the size limit is exceeded.
|
||||
* Preserves recent navigation while maintaining current position.
|
||||
*
|
||||
* @param locations - Array of tab locations to prune
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxSize - Maximum number of locations to keep
|
||||
* @returns Pruning result with updated locations and index
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With locations [A, B, C, D, E] and current=2 (C), maxSize=3
|
||||
* // Result: [C, D, E] with newCurrent=0 (still pointing to C)
|
||||
* ```
|
||||
*/
|
||||
private static pruneOldestFirst(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxSize: number,
|
||||
): HistoryPruningResult {
|
||||
const removeCount = locations.length - maxSize;
|
||||
const prunedLocations = locations.slice(removeCount);
|
||||
const newCurrent = Math.max(-1, current - removeCount);
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent,
|
||||
entriesRemoved: removeCount,
|
||||
strategy: 'oldest-first',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps entries balanced around the current position.
|
||||
*
|
||||
* Maintains a balanced window around the current location, preserving
|
||||
* 70% of entries before current and 30% after. This strategy provides
|
||||
* good back/forward navigation while respecting size limits.
|
||||
*
|
||||
* @param locations - Array of tab locations to prune
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxSize - Maximum number of locations to keep
|
||||
* @returns Pruning result with maintained current position
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With current=5 in 20 locations, maxSize=10
|
||||
* // Keeps ~7 entries before current, current entry, ~2 entries after
|
||||
* // Result preserves navigation context around current position
|
||||
* ```
|
||||
*/
|
||||
private static pruneBalanced(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxSize: number,
|
||||
): HistoryPruningResult {
|
||||
// Preserve 70% of entries before current, 30% after
|
||||
const backwardRatio = 0.7;
|
||||
const maxBackward = Math.floor(maxSize * backwardRatio);
|
||||
const maxForward = maxSize - maxBackward - 1; // -1 for current item
|
||||
|
||||
const keepStart = Math.max(0, current - maxBackward);
|
||||
const keepEnd = Math.min(locations.length, current + 1 + maxForward);
|
||||
|
||||
const prunedLocations = locations.slice(keepStart, keepEnd);
|
||||
const newCurrent = current - keepStart;
|
||||
const entriesRemoved = locations.length - prunedLocations.length;
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent,
|
||||
entriesRemoved,
|
||||
strategy: 'balanced',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligent pruning based on usage patterns and recency.
|
||||
*
|
||||
* Uses a scoring algorithm that considers both recency (how recently
|
||||
* a location was visited) and proximity (how close to current position).
|
||||
* Recent locations and those near the current position get higher scores
|
||||
* and are more likely to be preserved.
|
||||
*
|
||||
* Scoring factors:
|
||||
* - Recent (< 1 hour): 100 points base
|
||||
* - Medium (< 1 day): 60 points base
|
||||
* - Old (> 1 day): 20 points base
|
||||
* - Proximity: 100 - (distance_from_current * 10) points
|
||||
*
|
||||
* @param locations - Array of tab locations to prune
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxSize - Maximum number of locations to keep
|
||||
* @param config - Tab configuration (unused but kept for consistency)
|
||||
* @returns Pruning result with intelligently selected locations
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Preserves recently visited pages and those near current position
|
||||
* // while removing old, distant entries first
|
||||
* ```
|
||||
*/
|
||||
private static pruneSmart(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxSize: number,
|
||||
config: TabConfig,
|
||||
): HistoryPruningResult {
|
||||
const now = Date.now();
|
||||
const oneHour = 60 * 60 * 1000;
|
||||
const oneDay = 24 * oneHour;
|
||||
|
||||
// Score each location based on recency and distance from current
|
||||
const scoredLocations = locations.map((location, index) => {
|
||||
const age = now - location.timestamp;
|
||||
const distanceFromCurrent = Math.abs(index - current);
|
||||
|
||||
// Recent locations get higher scores
|
||||
let recencyScore = 100;
|
||||
if (age > oneDay) recencyScore = 20;
|
||||
else if (age > oneHour) recencyScore = 60;
|
||||
|
||||
// Locations near current position get higher scores
|
||||
const proximityScore = Math.max(0, 100 - distanceFromCurrent * 10);
|
||||
|
||||
return {
|
||||
location,
|
||||
index,
|
||||
score: recencyScore + proximityScore,
|
||||
isCurrent: index === current,
|
||||
};
|
||||
});
|
||||
|
||||
// Always keep current location and sort others by score
|
||||
const currentItem = scoredLocations.find((item) => item.isCurrent);
|
||||
const otherItems = scoredLocations
|
||||
.filter((item) => !item.isCurrent)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Take top scoring items
|
||||
const itemsToKeep = Math.min(maxSize - 1, otherItems.length); // -1 for current
|
||||
const keptItems = otherItems.slice(0, itemsToKeep);
|
||||
|
||||
if (currentItem) {
|
||||
keptItems.push(currentItem);
|
||||
}
|
||||
|
||||
// Sort by original index to maintain order
|
||||
keptItems.sort((a, b) => a.index - b.index);
|
||||
|
||||
const prunedLocations = keptItems.map((item) => item.location);
|
||||
const newCurrent = keptItems.findIndex((item) => item.isCurrent);
|
||||
const entriesRemoved = locations.length - prunedLocations.length;
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent:
|
||||
newCurrent === -1
|
||||
? Math.max(0, prunedLocations.length - 1)
|
||||
: newCurrent,
|
||||
entriesRemoved,
|
||||
strategy: 'smart',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prunes forward history when adding a new location.
|
||||
*
|
||||
* Limits the number of forward history entries that are preserved
|
||||
* when navigating to a new location. This prevents unlimited
|
||||
* forward history accumulation while maintaining reasonable redo depth.
|
||||
*
|
||||
* @param locations - Current array of tab locations
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxForwardHistory - Maximum forward entries to preserve
|
||||
* @returns Object with pruned locations and unchanged current index
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With current=2, maxForwardHistory=2
|
||||
* // [A, B, C, D, E, F] becomes [A, B, C, D, E]
|
||||
* // Preserves current position while limiting forward entries
|
||||
* ```
|
||||
*/
|
||||
static pruneForwardHistory(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxForwardHistory: number,
|
||||
): { locations: TabLocation[]; newCurrent: number } {
|
||||
if (current < 0 || current >= locations.length) {
|
||||
return { locations: [...locations], newCurrent: current };
|
||||
}
|
||||
|
||||
const beforeCurrent = locations.slice(0, current + 1);
|
||||
const afterCurrent = locations.slice(current + 1);
|
||||
|
||||
const limitedAfter = afterCurrent.slice(0, maxForwardHistory);
|
||||
const prunedLocations = [...beforeCurrent, ...limitedAfter];
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent: current, // Current position unchanged
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and corrects location history index.
|
||||
*
|
||||
* Ensures the current index is within valid bounds for the locations array.
|
||||
* Corrects invalid indices to the nearest valid value and reports whether
|
||||
* correction was needed. Essential for maintaining data integrity after
|
||||
* history modifications.
|
||||
*
|
||||
* @param locations - Array of tab locations to validate against
|
||||
* @param current - Current index to validate
|
||||
* @returns Object with corrected index and validation status
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { index, wasInvalid } = TabHistoryPruner.validateLocationIndex(
|
||||
* locations,
|
||||
* currentIndex
|
||||
* );
|
||||
* if (wasInvalid) {
|
||||
* console.warn(`Invalid index ${currentIndex} corrected to ${index}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static validateLocationIndex(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
): { index: number; wasInvalid: boolean } {
|
||||
if (locations.length === 0) {
|
||||
return { index: -1, wasInvalid: current !== -1 };
|
||||
}
|
||||
|
||||
if (current < -1 || current >= locations.length) {
|
||||
// Invalid index, correct to last valid position
|
||||
const correctedIndex = Math.max(
|
||||
-1,
|
||||
Math.min(locations.length - 1, current),
|
||||
);
|
||||
return { index: correctedIndex, wasInvalid: true };
|
||||
}
|
||||
|
||||
return { index: current, wasInvalid: false };
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable, inject, Injector } from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { TabService } from './tab';
|
||||
import { TabLocation } from './schemas';
|
||||
import { TabService } from '../tab.service';
|
||||
import { TabLocation } from '../schemas';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { HISTORY_BLACKLIST_PATTERNS } from './tab-navigation.constants';
|
||||
import { HISTORY_BLACKLIST_PATTERNS } from '../constants';
|
||||
|
||||
/**
|
||||
* Service that automatically syncs browser navigation events to tab location history.
|
||||
@@ -24,12 +24,19 @@ import { HISTORY_BLACKLIST_PATTERNS } from './tab-navigation.constants';
|
||||
* The service is designed to work seamlessly with the tab history pruning system,
|
||||
* providing fallback mechanisms when navigation history has been pruned.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@Injectable()
|
||||
export class TabNavigationService {
|
||||
#router = inject(Router);
|
||||
#tabService = inject(TabService);
|
||||
#injector = inject(Injector);
|
||||
#title = inject(Title);
|
||||
|
||||
// Lazy injection to avoid circular dependency:
|
||||
// TabNavigationService → TabService → UserStorageProvider → USER_SUB (requires auth)
|
||||
// The environment initializer runs before app initializer completes authentication
|
||||
#getTabService() {
|
||||
return this.#injector.get(TabService);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.#router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
@@ -51,14 +58,14 @@ export class TabNavigationService {
|
||||
const location = this.#createTabLocation(event.url);
|
||||
|
||||
// Check if this location already exists in history (browser back/forward)
|
||||
const currentTab = this.#tabService.entityMap()[activeTabId];
|
||||
const currentTab = this.#getTabService().entityMap()[activeTabId];
|
||||
if (
|
||||
currentTab &&
|
||||
this.#isLocationInHistory(currentTab.location.locations, location)
|
||||
) {
|
||||
this.#handleBrowserNavigation(activeTabId, location);
|
||||
} else {
|
||||
this.#tabService.navigateToLocation(activeTabId, location);
|
||||
this.#getTabService().navigateToLocation(activeTabId, location);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +79,9 @@ export class TabNavigationService {
|
||||
* @returns true if the URL should be skipped, false otherwise
|
||||
*/
|
||||
#shouldSkipHistory(url: string): boolean {
|
||||
return HISTORY_BLACKLIST_PATTERNS.some(pattern => url.startsWith(pattern));
|
||||
return HISTORY_BLACKLIST_PATTERNS.some((pattern) =>
|
||||
url.startsWith(pattern),
|
||||
);
|
||||
}
|
||||
|
||||
#getActiveTabId(url: string): number | null {
|
||||
@@ -91,7 +100,7 @@ export class TabNavigationService {
|
||||
}
|
||||
|
||||
// If no ID in URL, use currently activated tab
|
||||
return this.#tabService.activatedTabId();
|
||||
return this.#getTabService().activatedTabId();
|
||||
}
|
||||
|
||||
#createTabLocation(url: string): TabLocation {
|
||||
@@ -131,7 +140,7 @@ export class TabNavigationService {
|
||||
* @private
|
||||
*/
|
||||
#handleBrowserNavigation(tabId: number, location: TabLocation) {
|
||||
const currentTab = this.#tabService.entityMap()[tabId];
|
||||
const currentTab = this.#getTabService().entityMap()[tabId];
|
||||
if (!currentTab) return;
|
||||
|
||||
const locationIndex = currentTab.location.locations.findIndex(
|
||||
@@ -140,7 +149,7 @@ export class TabNavigationService {
|
||||
|
||||
// If location not found in history (possibly pruned), navigate to new location
|
||||
if (locationIndex === -1) {
|
||||
this.#tabService.navigateToLocation(tabId, location);
|
||||
this.#getTabService().navigateToLocation(tabId, location);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,7 +163,7 @@ export class TabNavigationService {
|
||||
const maxAttempts = Math.abs(steps) + 5; // Prevent infinite loops
|
||||
|
||||
while (steps > 0 && attempts < maxAttempts) {
|
||||
const result = this.#tabService.navigateBack(tabId);
|
||||
const result = this.#getTabService().navigateBack(tabId);
|
||||
if (!result) break;
|
||||
steps--;
|
||||
attempts++;
|
||||
@@ -162,7 +171,7 @@ export class TabNavigationService {
|
||||
|
||||
// If we couldn't reach the target, fallback to direct navigation
|
||||
if (steps > 0) {
|
||||
this.#tabService.navigateToLocation(tabId, location);
|
||||
this.#getTabService().navigateToLocation(tabId, location);
|
||||
}
|
||||
} else if (locationIndex > currentIndex) {
|
||||
// Navigate forward
|
||||
@@ -171,7 +180,7 @@ export class TabNavigationService {
|
||||
const maxAttempts = Math.abs(steps) + 5; // Prevent infinite loops
|
||||
|
||||
while (steps > 0 && attempts < maxAttempts) {
|
||||
const result = this.#tabService.navigateForward(tabId);
|
||||
const result = this.#getTabService().navigateForward(tabId);
|
||||
if (!result) break;
|
||||
steps--;
|
||||
attempts++;
|
||||
@@ -179,7 +188,7 @@ export class TabNavigationService {
|
||||
|
||||
// If we couldn't reach the target, fallback to direct navigation
|
||||
if (steps > 0) {
|
||||
this.#tabService.navigateToLocation(tabId, location);
|
||||
this.#getTabService().navigateToLocation(tabId, location);
|
||||
}
|
||||
}
|
||||
// If locationIndex === currentIndex, we're already at the right position
|
||||
@@ -210,7 +219,7 @@ export class TabNavigationService {
|
||||
|
||||
if (activeTabId) {
|
||||
const location = this.#createTabLocation(url);
|
||||
this.#tabService.navigateToLocation(activeTabId, location);
|
||||
this.#getTabService().navigateToLocation(activeTabId, location);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Injectable, inject, Injector } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
import { TITLE_PREFIX } from './title-prefix';
|
||||
import { TITLE_PREFIX } from '../constants';
|
||||
|
||||
/**
|
||||
* Custom TitleStrategy for the ISA application that:
|
||||
@@ -41,8 +41,8 @@ import { TITLE_PREFIX } from './title-prefix';
|
||||
* ];
|
||||
* ```
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IsaTitleStrategy extends TitleStrategy {
|
||||
@Injectable()
|
||||
export class TabTitleStrategy extends TitleStrategy {
|
||||
readonly #title = inject(Title);
|
||||
readonly #injector = inject(Injector);
|
||||
readonly #titlePrefix = inject(TITLE_PREFIX);
|
||||
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 { TabService } from './tab';
|
||||
import { Tab } from './schemas';
|
||||
import { TabService } from '../tab.service';
|
||||
import { Tab } from '../schemas';
|
||||
import { inject } from '@angular/core';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { getNextTabNameHelper } from './helpers';
|
||||
import { getNextTabNameHelper } from '../helpers';
|
||||
|
||||
export const tabResolverFn: ResolveFn<Tab> = (route) => {
|
||||
const log = logger(() => ({
|
||||
@@ -89,6 +89,24 @@ export const TabTagsSchema = z.array(z.string()).default([]);
|
||||
/** TypeScript type for tab tags */
|
||||
export type TabTags = z.infer<typeof TabTagsSchema>;
|
||||
|
||||
/**
|
||||
* Schema for tab icon route configuration.
|
||||
* Mirrors NavigationRoute from shell-navigation for icon click navigation.
|
||||
*/
|
||||
export const TabIconRouteSchema = z.object({
|
||||
/** The route path - can be a string or array of segments. */
|
||||
route: z.union([z.string(), z.array(z.unknown())]),
|
||||
/** Query parameters to append to the route. */
|
||||
queryParams: z
|
||||
.record(z.union([z.string(), z.number(), z.boolean()]))
|
||||
.optional(),
|
||||
/** Strategy for handling existing query parameters. */
|
||||
queryParamsHandling: z.enum(['merge', 'preserve', '']).optional(),
|
||||
});
|
||||
|
||||
/** TypeScript type for tab icon route */
|
||||
export type TabIconRoute = z.infer<typeof TabIconRouteSchema>;
|
||||
|
||||
/**
|
||||
* Base schema for tab validation (runtime validation only).
|
||||
*
|
||||
@@ -112,6 +130,12 @@ export const TabSchema = z.object({
|
||||
location: TabLocationHistorySchema,
|
||||
/** Array of tags for organization */
|
||||
tags: TabTagsSchema,
|
||||
/** SVG string for tab icon */
|
||||
icon: z.string().nullish(),
|
||||
/** Indicator badge (boolean for dot, number for count) */
|
||||
indicator: z.union([z.boolean(), z.number()]).nullish(),
|
||||
/** Route to navigate when icon is clicked */
|
||||
iconRoute: TabIconRouteSchema.nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -137,6 +161,12 @@ export interface Tab {
|
||||
location: TabLocationHistory;
|
||||
/** Organization tags */
|
||||
tags: string[];
|
||||
/** SVG string for tab icon */
|
||||
icon?: string | null;
|
||||
/** Indicator badge (boolean for dot, number for count) */
|
||||
indicator?: boolean | number | null;
|
||||
/** Route to navigate when icon is clicked */
|
||||
iconRoute?: TabIconRoute | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,6 +192,12 @@ export interface TabCreate {
|
||||
location: TabLocationHistory;
|
||||
/** Organization tags */
|
||||
tags: string[];
|
||||
/** SVG string for tab icon */
|
||||
icon?: string | null;
|
||||
/** Indicator badge (boolean for dot, number for count) */
|
||||
indicator?: boolean | number | null;
|
||||
/** Route to navigate when icon is clicked */
|
||||
iconRoute?: TabIconRoute | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,6 +224,12 @@ export const PersistedTabSchema = z
|
||||
location: TabLocationHistorySchema,
|
||||
/** Organization tags */
|
||||
tags: TabTagsSchema,
|
||||
/** SVG string for tab icon */
|
||||
icon: z.string().nullish(),
|
||||
/** Indicator badge (boolean for dot, number for count) */
|
||||
indicator: z.union([z.boolean(), z.number()]).nullish(),
|
||||
/** Route to navigate when icon is clicked */
|
||||
iconRoute: TabIconRouteSchema.nullish(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -213,6 +255,12 @@ export const AddTabSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
/** Optional activation timestamp */
|
||||
activatedAt: z.number().optional(),
|
||||
/** SVG string for tab icon */
|
||||
icon: z.string().nullish(),
|
||||
/** Indicator badge (boolean for dot, number for count) */
|
||||
indicator: z.union([z.boolean(), z.number()]).nullish(),
|
||||
/** Route to navigate when icon is clicked */
|
||||
iconRoute: TabIconRouteSchema.nullish(),
|
||||
});
|
||||
|
||||
/** TypeScript type for adding tabs */
|
||||
@@ -241,6 +289,12 @@ export const TabUpdateSchema = z
|
||||
location: TabLocationHistorySchema.optional(),
|
||||
/** Updated tags array */
|
||||
tags: z.array(z.string()).optional(),
|
||||
/** Updated SVG string for tab icon */
|
||||
icon: z.string().nullish(),
|
||||
/** Updated indicator badge (boolean for dot, number for count) */
|
||||
indicator: z.union([z.boolean(), z.number()]).nullish(),
|
||||
/** Updated route to navigate when icon is clicked */
|
||||
iconRoute: TabIconRouteSchema.nullish(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { computed, inject, Signal } from '@angular/core';
|
||||
import { computed, effect, inject, Signal, untracked } from '@angular/core';
|
||||
import { Params, QueryParamsHandling } from '@angular/router';
|
||||
import { Config } from '@isa/core/config';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { z } from 'zod';
|
||||
import { TabService } from './tab';
|
||||
import { TabService } from './tab.service';
|
||||
|
||||
const ReservedProcessIdsSchema = z.object({
|
||||
goodsOut: z.number(),
|
||||
@@ -251,3 +252,64 @@ export function injectLabeledLegacyTabRoute(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up reactive tab subtitle management.
|
||||
* Automatically updates the active tab's subtitle when the source signal changes.
|
||||
*
|
||||
* @param source - Signal containing the data to derive subtitle from
|
||||
* @param transform - Function to extract subtitle string from source data
|
||||
* @param fallback - Optional fallback string when transform returns undefined/null
|
||||
*
|
||||
* @example
|
||||
* // In component constructor:
|
||||
* useTabSubtitle(this.customerSignal, customer => getCustomerName(customer), 'Kundendetails');
|
||||
*/
|
||||
export function useTabSubtitle<T>(
|
||||
source: Signal<T>,
|
||||
transform: (value: T) => string | undefined | null,
|
||||
fallback?: string,
|
||||
): void {
|
||||
const tabService = inject(TabService);
|
||||
const tabId = injectTabId();
|
||||
const log = logger({ fn: 'useTabSubtitle' });
|
||||
|
||||
effect(() => {
|
||||
const value = source();
|
||||
const subtitle = transform(value) ?? fallback;
|
||||
|
||||
if (subtitle !== undefined && subtitle !== null) {
|
||||
untracked(() => {
|
||||
const id = tabId();
|
||||
if (id !== null && id !== undefined) {
|
||||
tabService.patchTab(id, { subtitle });
|
||||
log.debug('Tab subtitle updated', () => ({ tabId: id, subtitle }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up reactive tab subtitle from a query params signal.
|
||||
* Specialized helper for search components that derive subtitle from query parameters.
|
||||
*
|
||||
* @param queryParamsSource - Signal providing query params (Record<string, string>)
|
||||
* @param key - Query param key to extract subtitle from (default: 'main_qs')
|
||||
* @param fallback - Fallback string when key is empty (default: 'Suche')
|
||||
*
|
||||
* @example
|
||||
* // With Filter object - create a computed for query params:
|
||||
* filterParams = computed(() => this.filter()?.getQueryParams());
|
||||
* useQueryParamSubtitle(this.filterParams, 'main_qs', 'Kundensuche');
|
||||
*
|
||||
* // With custom key:
|
||||
* useQueryParamSubtitle(this.filterParams, 'search_term', 'Artikelsuche');
|
||||
*/
|
||||
export function useQueryParamSubtitle(
|
||||
queryParamsSource: Signal<Record<string, string> | undefined | null>,
|
||||
key = 'main_qs',
|
||||
fallback = 'Suche',
|
||||
): void {
|
||||
useTabSubtitle(queryParamsSource, (params) => params?.[key], fallback);
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ import {
|
||||
TabLocationHistory,
|
||||
} from './schemas';
|
||||
import { TAB_CONFIG } from './tab-config';
|
||||
import { TabHistoryPruner } from './tab-history-pruning';
|
||||
import { TabHistoryPruner } from './internal/tab-history-pruning';
|
||||
import { computed, inject } from '@angular/core';
|
||||
import { withDevtools } from '@angular-architects/ngrx-toolkit';
|
||||
import { CORE_TAB_ID_GENERATOR } from './tab-id.generator';
|
||||
import { CORE_TAB_ID_GENERATOR } from './internal/tab-id.generator';
|
||||
import { withStorage, UserStorageProvider } from '@isa/core/storage';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
@@ -403,16 +403,17 @@ export const TabService = signalStore(
|
||||
}));
|
||||
},
|
||||
/**
|
||||
* Gets the current location for a tab.
|
||||
* Gets the current location for a tab, validating and auto-correcting invalid indices.
|
||||
*
|
||||
* IMPORTANT: This method has a side effect - if index validation is enabled
|
||||
* and an invalid index is detected, it will automatically correct the index
|
||||
* in the store, triggering state updates and storage autosave.
|
||||
* **Side Effect Warning:** If index validation is enabled (`config.enableIndexValidation`)
|
||||
* and an invalid index is detected, this method will automatically correct the index
|
||||
* in the store, triggering state updates and storage autosave. This behavior exists
|
||||
* to maintain data integrity for corrupted tab history.
|
||||
*
|
||||
* @param id - The tab ID
|
||||
* @returns The current location or null if tab doesn't exist or history is empty
|
||||
*/
|
||||
getCurrentLocation(id: number) {
|
||||
getCurrentLocationWithValidation(id: number) {
|
||||
const currentTab = store.entityMap()[id];
|
||||
if (!currentTab) return null;
|
||||
|
||||
@@ -427,7 +428,7 @@ export const TabService = signalStore(
|
||||
|
||||
if (wasInvalid && store._config.enableIndexValidation) {
|
||||
store._logger.warn(
|
||||
'Invalid location index corrected in getCurrentLocation',
|
||||
'Invalid location index corrected in getCurrentLocationWithValidation',
|
||||
() => ({
|
||||
tabId: id,
|
||||
invalidIndex: currentLocation.current,
|
||||
@@ -79,6 +79,12 @@ export class ShellNavigationItemComponent {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Validate SVG structure before bypassing security
|
||||
const trimmed = icon.trim();
|
||||
if (!trimmed.startsWith('<svg') || !trimmed.includes('</svg>')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.#sanitizer.bypassSecurityTrustHtml(icon);
|
||||
});
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ export const navigations: Array<NavigationGroup | NavigationItem> = [
|
||||
() => {
|
||||
const routeSignal = injectLabeledTabRoute('Remission', [
|
||||
'remission',
|
||||
'mandatory',
|
||||
]);
|
||||
const remissionStore = inject(RemissionStore);
|
||||
return computed(() => ({
|
||||
|
||||
@@ -79,3 +79,76 @@ a.compact button {
|
||||
a.compact .close-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Icon container */
|
||||
.icon-container {
|
||||
@apply relative size-5 shrink-0 flex items-center justify-center;
|
||||
}
|
||||
|
||||
/* Clickable icon button */
|
||||
.icon-button {
|
||||
@apply relative size-5 flex items-center justify-center
|
||||
rounded transition-colors duration-150
|
||||
hover:bg-isa-neutral-300 focus:bg-isa-neutral-300
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-isa-neutral-500;
|
||||
}
|
||||
|
||||
/* SVG icon */
|
||||
.icon-svg {
|
||||
@apply size-4;
|
||||
}
|
||||
|
||||
.icon-svg :deep(svg) {
|
||||
@apply size-full;
|
||||
}
|
||||
|
||||
/* Boolean indicator dot */
|
||||
.indicator-dot {
|
||||
@apply absolute -top-0.5 -right-0.5 size-2 rounded-full bg-isa-accent-red;
|
||||
animation: pulse-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Numeric indicator badge */
|
||||
.indicator-badge {
|
||||
@apply absolute -top-1 -right-1.5 min-w-4 h-4 px-1
|
||||
flex items-center justify-center
|
||||
rounded-full bg-isa-accent-red
|
||||
text-isa-white text-[10px] font-semibold leading-none;
|
||||
animation: pulse-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Compact mode indicator */
|
||||
.compact-indicator {
|
||||
@apply absolute left-1 top-1/2 -translate-y-1/2
|
||||
size-1.5 rounded-full bg-isa-accent-red;
|
||||
animation: pulse-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Pulse animation */
|
||||
@keyframes pulse-in {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.indicator-dot,
|
||||
.indicator-badge,
|
||||
.compact-indicator {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide icon container in compact mode */
|
||||
a.compact .icon-container {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,71 @@
|
||||
@let _route = route();
|
||||
@let _tab = tab();
|
||||
@let _compact = compact();
|
||||
@let _hasIcon = _tab.icon || showIndicator();
|
||||
|
||||
<a
|
||||
class="w-full flex flex-row min-w-[10rem] max-w-[11.5rem] px-3 pt-2 justify-start items-start gap-2 rounded-t-2xl bg-isa-neutral-200 overflow-hidden transition-all duration-200"
|
||||
[class.compact]="compact()"
|
||||
[class.compact]="_compact"
|
||||
[routerLink]="_route?.urlTree ?? '/'"
|
||||
[title]="_route?.title ?? ''"
|
||||
data-what="tab-link"
|
||||
[attr.data-which]="tab().id"
|
||||
[attr.data-which]="_tab.id"
|
||||
[attr.aria-current]="active() ? 'page' : null"
|
||||
>
|
||||
<div class="grow min-w-0">
|
||||
<div class="isa-text-caption-bold truncate w-full">
|
||||
{{ tab().name }}
|
||||
<!-- Icon/Indicator Container (expanded mode only) -->
|
||||
@if (!_compact && _hasIcon) {
|
||||
<div class="icon-container" data-what="tab-icon" [attr.data-which]="_tab.id">
|
||||
@if (hasIconRoute()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="navigateIconRoute($event)"
|
||||
class="icon-button"
|
||||
[attr.aria-label]="'Navigate from ' + _tab.name"
|
||||
>
|
||||
@if (_tab.icon) {
|
||||
<div class="icon-svg" [innerHTML]="sanitizedIcon()"></div>
|
||||
}
|
||||
@if (showIndicator()) {
|
||||
<span
|
||||
[class]="indicatorText() ? 'indicator-badge' : 'indicator-dot'"
|
||||
aria-hidden="true"
|
||||
>{{ indicatorText() }}</span>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
@if (_tab.icon) {
|
||||
<div class="icon-svg" [innerHTML]="sanitizedIcon()"></div>
|
||||
}
|
||||
@if (showIndicator()) {
|
||||
<span
|
||||
[class]="indicatorText() ? 'indicator-badge' : 'indicator-dot'"
|
||||
aria-hidden="true"
|
||||
>{{ indicatorText() }}</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="subtitle" [class.collapsed]="compact()">
|
||||
<span class="isa-text-caption-regular w-full">
|
||||
{{ tab().subtitle }}
|
||||
</span>
|
||||
}
|
||||
|
||||
<!-- Compact mode indicator -->
|
||||
@if (_compact && showIndicator()) {
|
||||
<span class="compact-indicator" aria-hidden="true"></span>
|
||||
}
|
||||
|
||||
<!-- Name and subtitle -->
|
||||
<div class="grow min-w-0">
|
||||
<div class="isa-text-caption-bold truncate w-full">{{ _tab.name }}</div>
|
||||
<div class="subtitle" [class.collapsed]="_compact">
|
||||
<span class="isa-text-caption-regular w-full">{{ _tab.subtitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
(click)="close($event)"
|
||||
class="grow-0"
|
||||
data-what="button"
|
||||
data-which="close-tab"
|
||||
[attr.aria-label]="'Tab schließen: ' + tab().name"
|
||||
[attr.aria-label]="'Tab schließen: ' + _tab.name"
|
||||
>
|
||||
<ng-icon name="isaActionClose" class="close-icon"></ng-icon>
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
inject,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { Tab } from '@isa/core/tabs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -42,6 +43,7 @@ export class ShellTabItemComponent {
|
||||
#logger = logger({ component: 'ShellTabItemComponent' });
|
||||
#router = inject(Router);
|
||||
#tabService = inject(TabService);
|
||||
#sanitizer = inject(DomSanitizer);
|
||||
|
||||
/** The tab entity to display. */
|
||||
tab = input.required<Tab>();
|
||||
@@ -58,7 +60,7 @@ export class ShellTabItemComponent {
|
||||
/** The route for this tab, parsed as a UrlTree for proper auxiliary route handling. */
|
||||
route = computed(() => {
|
||||
const tab = this.tab();
|
||||
const location = this.#tabService.getCurrentLocation(tab.id);
|
||||
const location = this.#tabService.getCurrentLocationWithValidation(tab.id);
|
||||
if (!location?.url) {
|
||||
return null;
|
||||
}
|
||||
@@ -69,6 +71,57 @@ export class ShellTabItemComponent {
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Sanitized HTML for the tab icon SVG.
|
||||
* Returns empty string if no icon is set or if invalid SVG structure.
|
||||
*/
|
||||
sanitizedIcon = computed(() => {
|
||||
const icon = this.tab().icon;
|
||||
if (!icon) return '';
|
||||
|
||||
// Validate SVG structure before bypassing security
|
||||
const trimmed = icon.trim();
|
||||
if (!trimmed.startsWith('<svg') || !trimmed.includes('</svg>')) {
|
||||
this.#logger.warn('Invalid SVG icon format', () => ({
|
||||
tabId: this.tab().id,
|
||||
iconPreview: icon.substring(0, 50),
|
||||
}));
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.#sanitizer.bypassSecurityTrustHtml(icon);
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether to show an indicator badge/dot.
|
||||
* True when indicator is truthy (boolean true or number > 0).
|
||||
*/
|
||||
showIndicator = computed(() => {
|
||||
const indicator = this.tab().indicator;
|
||||
if (indicator === null || indicator === undefined) return false;
|
||||
if (typeof indicator === 'boolean') return indicator;
|
||||
return indicator > 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Text to display for numeric indicators.
|
||||
* Returns "9+" for numbers > 9, otherwise the number as string.
|
||||
* Returns null for boolean indicators (dot only).
|
||||
*/
|
||||
indicatorText = computed(() => {
|
||||
const indicator = this.tab().indicator;
|
||||
if (typeof indicator !== 'number') return null;
|
||||
return indicator > 9 ? '9+' : indicator.toString();
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the icon has a click route configured.
|
||||
*/
|
||||
hasIconRoute = computed(() => {
|
||||
const iconRoute = this.tab().iconRoute;
|
||||
return iconRoute !== null && iconRoute !== undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* Closes this tab and navigates to the previously active tab.
|
||||
* If no previous tab exists, navigates to the root route.
|
||||
@@ -84,7 +137,7 @@ export class ShellTabItemComponent {
|
||||
const previousTab = this.#tabService.removeTab(tabId);
|
||||
|
||||
if (previousTab) {
|
||||
const location = this.#tabService.getCurrentLocation(previousTab.id);
|
||||
const location = this.#tabService.getCurrentLocationWithValidation(previousTab.id);
|
||||
if (location?.url) {
|
||||
await this.#router.navigateByUrl(location.url);
|
||||
return;
|
||||
@@ -96,4 +149,38 @@ export class ShellTabItemComponent {
|
||||
this.#logger.error('Failed to close tab', error as Error, () => ({ tabId }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the icon route when the icon is clicked.
|
||||
* Prevents the click from bubbling to the tab link.
|
||||
*/
|
||||
async navigateIconRoute(event: MouseEvent): Promise<void> {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const iconRoute = this.tab().iconRoute;
|
||||
if (!iconRoute) return;
|
||||
|
||||
const tabId = this.tab().id;
|
||||
this.#logger.debug('Navigating via icon route', () => ({
|
||||
tabId,
|
||||
route: iconRoute.route,
|
||||
}));
|
||||
|
||||
try {
|
||||
const route =
|
||||
typeof iconRoute.route === 'string'
|
||||
? [iconRoute.route]
|
||||
: iconRoute.route;
|
||||
|
||||
await this.#router.navigate(route as unknown[], {
|
||||
queryParams: iconRoute.queryParams,
|
||||
queryParamsHandling: iconRoute.queryParamsHandling || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
this.#logger.error('Failed to navigate via icon route', error as Error, () => ({
|
||||
tabId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,6 @@
|
||||
"@isa/common/data-access": ["libs/common/data-access/src/index.ts"],
|
||||
"@isa/common/decorators": ["libs/common/decorators/src/index.ts"],
|
||||
"@isa/common/print": ["libs/common/print/src/index.ts"],
|
||||
"@isa/common/title-management": [
|
||||
"libs/common/title-management/src/index.ts"
|
||||
],
|
||||
"@isa/core/auth": ["libs/core/auth/src/index.ts"],
|
||||
"@isa/core/config": ["libs/core/config/src/index.ts"],
|
||||
"@isa/core/connectivity": ["libs/core/connectivity/src/index.ts"],
|
||||
|
||||
Reference in New Issue
Block a user