mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 2065: ♻️ refactor(core-navigation): remove library and use TabService directly
♻️ refactor(core-navigation): remove library and use TabService directly Remove @isa/core/navigation library entirely as it was just a thin wrapper around TabService.patchTabMetadata(). Consumers now use TabService directly for scoped metadata operations. Changes: - Delete libs/core/navigation/ (~12 files, ~2900 LOC removed) - Update 6 consumer components to use TabService directly - Remove @isa/core/navigation path alias from tsconfig.base.json - All operations now synchronous (removed async/await) Migration pattern: - preserveContext() → patchTabMetadata(tabId, { [scope]: data }) - restoreContext() → activatedTab()?.metadata?.[scope] - restoreAndClearContext() → get + patchTabMetadata(tabId, { [scope]: null }) Refs #5502
This commit is contained in:
committed by
Nino Righi
parent
a3c865e39c
commit
fdfb54a3a0
@@ -9,8 +9,7 @@ import { DecimalPipe } from '@angular/common';
|
|||||||
import { Component, Input, OnInit, inject } from '@angular/core';
|
import { Component, Input, OnInit, inject } from '@angular/core';
|
||||||
import { IconComponent } from '@shared/components/icon';
|
import { IconComponent } from '@shared/components/icon';
|
||||||
import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
|
import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
|
||||||
import { injectTabId } from '@isa/core/tabs';
|
import { injectTabId, TabService } from '@isa/core/tabs';
|
||||||
import { NavigationStateService } from '@isa/core/navigation';
|
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ import { CustomerSearchNavigation } from '@shared/services/navigation';
|
|||||||
export class KundenkarteComponent implements OnInit {
|
export class KundenkarteComponent implements OnInit {
|
||||||
#tabId = injectTabId();
|
#tabId = injectTabId();
|
||||||
#router = inject(Router);
|
#router = inject(Router);
|
||||||
#navigationState = inject(NavigationStateService);
|
#tabService = inject(TabService);
|
||||||
#customerNavigationService = inject(CustomerSearchNavigation);
|
#customerNavigationService = inject(CustomerSearchNavigation);
|
||||||
|
|
||||||
@Input() cardDetails: BonusCardInfoDTO;
|
@Input() cardDetails: BonusCardInfoDTO;
|
||||||
@@ -69,13 +68,12 @@ export class KundenkarteComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#navigationState.preserveContext(
|
this.#tabService.patchTabMetadata(tabId, {
|
||||||
{
|
'select-customer': {
|
||||||
returnUrl: `/${tabId}/reward`,
|
returnUrl: `/${tabId}/reward`,
|
||||||
autoTriggerContinueFn: true,
|
autoTriggerContinueFn: true,
|
||||||
},
|
},
|
||||||
'select-customer',
|
});
|
||||||
);
|
|
||||||
|
|
||||||
await this.#router.navigate(
|
await this.#router.navigate(
|
||||||
this.#customerNavigationService.detailsRoute({
|
this.#customerNavigationService.detailsRoute({
|
||||||
|
|||||||
@@ -49,9 +49,14 @@ import {
|
|||||||
NavigateAfterRewardSelection,
|
NavigateAfterRewardSelection,
|
||||||
RewardSelectionPopUpService,
|
RewardSelectionPopUpService,
|
||||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||||
import { NavigationStateService } from '@isa/core/navigation';
|
import { TabService } from '@isa/core/tabs';
|
||||||
import { ShippingAddressDTO as CrmShippingAddressDTO } from '@generated/swagger/crm-api';
|
import { ShippingAddressDTO as CrmShippingAddressDTO } from '@generated/swagger/crm-api';
|
||||||
|
|
||||||
|
interface SelectCustomerContext {
|
||||||
|
returnUrl?: string;
|
||||||
|
autoTriggerContinueFn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CustomerDetailsViewMainState {
|
export interface CustomerDetailsViewMainState {
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
shoppingCart: ShoppingCartDTO;
|
shoppingCart: ShoppingCartDTO;
|
||||||
@@ -80,7 +85,7 @@ export class CustomerDetailsViewMainComponent
|
|||||||
private _router = inject(Router);
|
private _router = inject(Router);
|
||||||
private _activatedRoute = inject(ActivatedRoute);
|
private _activatedRoute = inject(ActivatedRoute);
|
||||||
private _genderSettings = inject(GenderSettingsService);
|
private _genderSettings = inject(GenderSettingsService);
|
||||||
private _navigationState = inject(NavigationStateService);
|
private _tabService = inject(TabService);
|
||||||
private _onDestroy$ = new Subject<void>();
|
private _onDestroy$ = new Subject<void>();
|
||||||
|
|
||||||
customerService = inject(CrmCustomerService);
|
customerService = inject(CrmCustomerService);
|
||||||
@@ -97,18 +102,19 @@ export class CustomerDetailsViewMainComponent
|
|||||||
map(([fetchingCustomer, fetchingList]) => fetchingCustomer || fetchingList),
|
map(([fetchingCustomer, fetchingList]) => fetchingCustomer || fetchingList),
|
||||||
);
|
);
|
||||||
|
|
||||||
async getReturnUrlFromContext(): Promise<string | null> {
|
getReturnUrlFromContext(): string | null {
|
||||||
// Get from preserved context (survives intermediate navigations, auto-scoped to tab)
|
// Get from preserved context (survives intermediate navigations, scoped to tab)
|
||||||
const context = await this._navigationState.restoreContext<{
|
const context = this._tabService.activatedTab()?.metadata?.[
|
||||||
returnUrl?: string;
|
'select-customer'
|
||||||
}>('select-customer');
|
] as SelectCustomerContext | undefined;
|
||||||
|
|
||||||
return context?.returnUrl ?? null;
|
return context?.returnUrl ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkHasReturnUrl(): Promise<void> {
|
checkHasReturnUrl(): void {
|
||||||
const hasContext =
|
const hasContext = !!this._tabService.activatedTab()?.metadata?.[
|
||||||
await this._navigationState.hasPreservedContext('select-customer');
|
'select-customer'
|
||||||
|
];
|
||||||
this.hasReturnUrl.set(hasContext);
|
this.hasReturnUrl.set(hasContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,24 +327,23 @@ export class CustomerDetailsViewMainComponent
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
// Check if we have a return URL context
|
// Check if we have a return URL context
|
||||||
this.checkHasReturnUrl().then(async () => {
|
this.checkHasReturnUrl();
|
||||||
// Check if we should auto-trigger continue() (only from Kundenkarte)
|
|
||||||
const context = await this._navigationState.restoreContext<{
|
|
||||||
returnUrl?: string;
|
|
||||||
autoTriggerContinueFn?: boolean;
|
|
||||||
}>('select-customer');
|
|
||||||
|
|
||||||
if (context?.autoTriggerContinueFn) {
|
// Check if we should auto-trigger continue() (only from Kundenkarte)
|
||||||
// Clear the autoTriggerContinueFn flag immediately (preserves returnUrl automatically)
|
const tab = this._tabService.activatedTab();
|
||||||
await this._navigationState.patchContext(
|
const context = tab?.metadata?.['select-customer'] as
|
||||||
{ autoTriggerContinueFn: undefined },
|
| SelectCustomerContext
|
||||||
'select-customer',
|
| undefined;
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-trigger continue() ONLY when coming from Kundenkarte
|
if (context?.autoTriggerContinueFn && tab) {
|
||||||
this.continue();
|
// Clear the autoTriggerContinueFn flag immediately (preserves returnUrl)
|
||||||
}
|
this._tabService.patchTabMetadata(tab.id, {
|
||||||
});
|
'select-customer': { ...context, autoTriggerContinueFn: undefined },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-trigger continue() ONLY when coming from Kundenkarte
|
||||||
|
this.continue();
|
||||||
|
}
|
||||||
|
|
||||||
this.processId$
|
this.processId$
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -436,10 +441,18 @@ export class CustomerDetailsViewMainComponent
|
|||||||
|
|
||||||
// #5262 Check for reward selection flow before navigation
|
// #5262 Check for reward selection flow before navigation
|
||||||
if (this.hasReturnUrl()) {
|
if (this.hasReturnUrl()) {
|
||||||
// Restore from preserved context (auto-scoped to current tab) and clean up
|
// Restore from preserved context (scoped to current tab) and clean up
|
||||||
const context = await this._navigationState.restoreAndClearContext<{
|
const tab = this._tabService.activatedTab();
|
||||||
returnUrl?: string;
|
const context = tab?.metadata?.['select-customer'] as
|
||||||
}>('select-customer');
|
| SelectCustomerContext
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// Clear the context
|
||||||
|
if (tab) {
|
||||||
|
this._tabService.patchTabMetadata(tab.id, {
|
||||||
|
'select-customer': null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (context?.returnUrl) {
|
if (context?.returnUrl) {
|
||||||
await this._router.navigateByUrl(context.returnUrl);
|
await this._router.navigateByUrl(context.returnUrl);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { CustomerSearchStore } from '../store';
|
import { CustomerSearchStore } from '../store';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { NavigationStateService } from '@isa/core/navigation';
|
import { TabService } from '@isa/core/tabs';
|
||||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
||||||
import { AsyncPipe } from '@angular/common';
|
import { AsyncPipe } from '@angular/common';
|
||||||
import { CustomerMenuComponent } from '../../components/customer-menu';
|
import { CustomerMenuComponent } from '../../components/customer-menu';
|
||||||
@@ -51,7 +51,7 @@ export class KundenkarteMainViewComponent implements OnDestroy {
|
|||||||
#cardTransactionsResource = inject(CustomerCardTransactionsResource);
|
#cardTransactionsResource = inject(CustomerCardTransactionsResource);
|
||||||
elementRef = inject(ElementRef);
|
elementRef = inject(ElementRef);
|
||||||
#router = inject(Router);
|
#router = inject(Router);
|
||||||
#navigationState = inject(NavigationStateService);
|
#tabService = inject(TabService);
|
||||||
#customerNavigationService = inject(CustomerSearchNavigation);
|
#customerNavigationService = inject(CustomerSearchNavigation);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,13 +120,12 @@ export class KundenkarteMainViewComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Preserve context for auto-triggering continue() in details view
|
// Preserve context for auto-triggering continue() in details view
|
||||||
this.#navigationState.preserveContext(
|
this.#tabService.patchTabMetadata(tabId, {
|
||||||
{
|
'select-customer': {
|
||||||
returnUrl: `/${tabId}/reward`,
|
returnUrl: `/${tabId}/reward`,
|
||||||
autoTriggerContinueFn: true,
|
autoTriggerContinueFn: true,
|
||||||
},
|
},
|
||||||
'select-customer',
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Navigate to customer details - will auto-trigger continue()
|
// Navigate to customer details - will auto-trigger continue()
|
||||||
await this.#router.navigate(
|
await this.#router.navigate(
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ import {
|
|||||||
ShoppingCartFacade,
|
ShoppingCartFacade,
|
||||||
SelectedRewardShoppingCartResource,
|
SelectedRewardShoppingCartResource,
|
||||||
} from '@isa/checkout/data-access';
|
} from '@isa/checkout/data-access';
|
||||||
import { injectTabId } from '@isa/core/tabs';
|
import { injectTabId, TabService } from '@isa/core/tabs';
|
||||||
import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons';
|
import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons';
|
||||||
import { PurchaseOptionsModalService } from '@modal/purchase-options';
|
import { PurchaseOptionsModalService } from '@modal/purchase-options';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { getRouteToCustomer } from '../helpers';
|
import { getRouteToCustomer } from '../helpers';
|
||||||
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
|
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
|
||||||
import { NavigationStateService } from '@isa/core/navigation';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'reward-action',
|
selector: 'reward-action',
|
||||||
@@ -32,7 +31,7 @@ export class RewardActionComponent {
|
|||||||
#store = inject(RewardCatalogStore);
|
#store = inject(RewardCatalogStore);
|
||||||
#tabId = injectTabId();
|
#tabId = injectTabId();
|
||||||
|
|
||||||
#navigationState = inject(NavigationStateService);
|
#tabService = inject(TabService);
|
||||||
#purchasingOptionsModal = inject(PurchaseOptionsModalService);
|
#purchasingOptionsModal = inject(PurchaseOptionsModalService);
|
||||||
#shoppingCartFacade = inject(ShoppingCartFacade);
|
#shoppingCartFacade = inject(ShoppingCartFacade);
|
||||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||||
@@ -122,12 +121,9 @@ export class RewardActionComponent {
|
|||||||
const route = getRouteToCustomer(tabId);
|
const route = getRouteToCustomer(tabId);
|
||||||
|
|
||||||
// Preserve context: Store current reward page URL to return to after customer selection
|
// Preserve context: Store current reward page URL to return to after customer selection
|
||||||
await this.#navigationState.preserveContext(
|
this.#tabService.patchTabMetadata(tabId, {
|
||||||
{
|
'select-customer': { returnUrl: this.#router.url },
|
||||||
returnUrl: this.#router.url,
|
});
|
||||||
},
|
|
||||||
'select-customer',
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.#router.navigate(route.path, {
|
await this.#router.navigate(route.path, {
|
||||||
queryParams: route.queryParams,
|
queryParams: route.queryParams,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { ButtonComponent } from '@isa/ui/buttons';
|
import { ButtonComponent } from '@isa/ui/buttons';
|
||||||
import { injectTabId } from '@isa/core/tabs';
|
import { injectTabId, TabService } from '@isa/core/tabs';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { getRouteToCustomer } from '../../helpers';
|
import { getRouteToCustomer } from '../../helpers';
|
||||||
import { NavigationStateService } from '@isa/core/navigation';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'reward-start-card',
|
selector: 'reward-start-card',
|
||||||
@@ -13,7 +12,7 @@ import { NavigationStateService } from '@isa/core/navigation';
|
|||||||
imports: [ButtonComponent],
|
imports: [ButtonComponent],
|
||||||
})
|
})
|
||||||
export class RewardStartCardComponent {
|
export class RewardStartCardComponent {
|
||||||
readonly #navigationState = inject(NavigationStateService);
|
readonly #tabService = inject(TabService);
|
||||||
readonly #router = inject(Router);
|
readonly #router = inject(Router);
|
||||||
|
|
||||||
tabId = injectTabId();
|
tabId = injectTabId();
|
||||||
@@ -22,19 +21,19 @@ export class RewardStartCardComponent {
|
|||||||
* Called when "Kund*in auswählen" button is clicked.
|
* Called when "Kund*in auswählen" button is clicked.
|
||||||
* Preserves the current URL as returnUrl before navigating to customer search.
|
* Preserves the current URL as returnUrl before navigating to customer search.
|
||||||
*/
|
*/
|
||||||
async onSelectCustomer() {
|
onSelectCustomer() {
|
||||||
const customerRoute = getRouteToCustomer(this.tabId());
|
const customerRoute = getRouteToCustomer(this.tabId());
|
||||||
|
const tabId = this.#tabService.activatedTabId();
|
||||||
|
|
||||||
// Preserve context: Store current reward page URL to return to after customer selection
|
// Preserve context: Store current reward page URL to return to after customer selection
|
||||||
await this.#navigationState.preserveContext(
|
if (tabId) {
|
||||||
{
|
this.#tabService.patchTabMetadata(tabId, {
|
||||||
returnUrl: this.#router.url,
|
'select-customer': { returnUrl: this.#router.url },
|
||||||
},
|
});
|
||||||
'select-customer',
|
}
|
||||||
);
|
|
||||||
|
|
||||||
// Navigate to customer search
|
// Navigate to customer search
|
||||||
await this.#router.navigate(customerRoute.path, {
|
this.#router.navigate(customerRoute.path, {
|
||||||
queryParams: customerRoute.queryParams,
|
queryParams: customerRoute.queryParams,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import { isaActionEdit } from '@isa/icons';
|
|||||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||||
import { provideIcons } from '@ng-icons/core';
|
import { provideIcons } from '@ng-icons/core';
|
||||||
import { AddressComponent } from '@isa/shared/address';
|
import { AddressComponent } from '@isa/shared/address';
|
||||||
import { injectTabId } from '@isa/core/tabs';
|
import { injectTabId, TabService } from '@isa/core/tabs';
|
||||||
import { NavigationStateService } from '@isa/core/navigation';
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'checkout-billing-and-shipping-address-card',
|
selector: 'checkout-billing-and-shipping-address-card',
|
||||||
@@ -26,7 +26,8 @@ import { NavigationStateService } from '@isa/core/navigation';
|
|||||||
providers: [provideIcons({ isaActionEdit })],
|
providers: [provideIcons({ isaActionEdit })],
|
||||||
})
|
})
|
||||||
export class BillingAndShippingAddressCardComponent {
|
export class BillingAndShippingAddressCardComponent {
|
||||||
#navigationState = inject(NavigationStateService);
|
#tabService = inject(TabService);
|
||||||
|
#router = inject(Router);
|
||||||
#shippingAddressResource = inject(SelectedCustomerShippingAddressResource);
|
#shippingAddressResource = inject(SelectedCustomerShippingAddressResource);
|
||||||
#payerAddressResource = inject(SelectedCustomerPayerAddressResource);
|
#payerAddressResource = inject(SelectedCustomerPayerAddressResource);
|
||||||
|
|
||||||
@@ -45,18 +46,19 @@ export class BillingAndShippingAddressCardComponent {
|
|||||||
return this.#customerResource.value();
|
return this.#customerResource.value();
|
||||||
});
|
});
|
||||||
|
|
||||||
async navigateToCustomer() {
|
navigateToCustomer() {
|
||||||
const customerId = this.customer()?.id;
|
const customerId = this.customer()?.id;
|
||||||
if (!customerId) return;
|
const tabId = this.tabId();
|
||||||
|
if (!customerId || !tabId) return;
|
||||||
|
|
||||||
const returnUrl = `/${this.tabId()}/reward/cart`;
|
const returnUrl = `/${tabId}/reward/cart`;
|
||||||
|
|
||||||
// Preserve context across intermediate navigations (auto-scoped to active tab)
|
// Preserve context across intermediate navigations (scoped to active tab)
|
||||||
await this.#navigationState.navigateWithPreservedContext(
|
this.#tabService.patchTabMetadata(tabId, {
|
||||||
['/', 'kunde', this.tabId(), 'customer', 'search', customerId],
|
'select-customer': { returnUrl },
|
||||||
{ returnUrl },
|
});
|
||||||
'select-customer',
|
|
||||||
);
|
this.#router.navigate(['/', 'kunde', tabId, 'customer', 'search', customerId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
payer = computed(() => {
|
payer = computed(() => {
|
||||||
|
|||||||
@@ -1,855 +0,0 @@
|
|||||||
# @isa/core/navigation
|
|
||||||
|
|
||||||
A reusable Angular library providing **context preservation** for multi-step navigation flows with automatic tab-scoped storage.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@isa/core/navigation` solves the problem of **lost navigation state** during intermediate navigations. Unlike Angular's router state which is lost after intermediate navigations, this library persists navigation context in **tab metadata** with automatic cleanup when tabs close.
|
|
||||||
|
|
||||||
### The Problem It Solves
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ Problem: Router state is lost during intermediate navigations
|
|
||||||
await router.navigate(['/customer/search'], {
|
|
||||||
state: { returnUrl: '/reward/cart' } // Works for immediate navigation
|
|
||||||
});
|
|
||||||
|
|
||||||
// After intermediate navigations:
|
|
||||||
// /customer/search → /customer/details → /add-shipping-address
|
|
||||||
// ⚠️ The returnUrl is LOST!
|
|
||||||
```
|
|
||||||
|
|
||||||
### The Solution
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Solution: Context preservation survives intermediate navigations
|
|
||||||
navState.preserveContext({ returnUrl: '/reward/cart' });
|
|
||||||
// Context persists in tab metadata, automatically cleaned up when tab closes
|
|
||||||
|
|
||||||
// After multiple intermediate navigations:
|
|
||||||
const context = navState.restoreAndClearContext();
|
|
||||||
// ✅ returnUrl is PRESERVED!
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- ✅ **Survives Intermediate Navigations** - State persists across multiple navigation steps
|
|
||||||
- ✅ **Automatic Tab Scoping** - Contexts automatically isolated per tab using `TabService`
|
|
||||||
- ✅ **Automatic Cleanup** - Contexts cleared automatically when tabs close (no manual cleanup needed)
|
|
||||||
- ✅ **Hierarchical Scoping** - Combine tab ID with custom scopes (e.g., `"customer-details"`)
|
|
||||||
- ✅ **Type-Safe** - Full TypeScript generics support
|
|
||||||
- ✅ **Simple API** - No context IDs to track, scope is the identifier
|
|
||||||
- ✅ **Auto-Restore on Refresh** - Contexts survive page refresh (via TabService UserStorage persistence)
|
|
||||||
- ✅ **Map-Based Storage** - One context per scope for clarity
|
|
||||||
- ✅ **Platform-Agnostic** - Works with Angular Universal (SSR)
|
|
||||||
- ✅ **Zero URL Pollution** - No query parameters needed
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
This library is part of the ISA Frontend monorepo. Import from the path alias:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { NavigationStateService } from '@isa/core/navigation';
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Basic Flow
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { NavigationStateService } from '@isa/core/navigation';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-cart',
|
|
||||||
template: `<button (click)="editCustomer()">Edit Customer</button>`
|
|
||||||
})
|
|
||||||
export class CartComponent {
|
|
||||||
private router = inject(Router);
|
|
||||||
private navState = inject(NavigationStateService);
|
|
||||||
|
|
||||||
async editCustomer() {
|
|
||||||
// Start flow - preserve context (auto-scoped to active tab)
|
|
||||||
this.navState.preserveContext({
|
|
||||||
returnUrl: '/reward/cart',
|
|
||||||
customerId: 123
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.router.navigate(['/customer/search']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-customer-details',
|
|
||||||
template: `<button (click)="complete()">Complete</button>`
|
|
||||||
})
|
|
||||||
export class CustomerDetailsComponent {
|
|
||||||
private router = inject(Router);
|
|
||||||
private navState = inject(NavigationStateService);
|
|
||||||
|
|
||||||
async complete() {
|
|
||||||
// End flow - restore and auto-cleanup (auto-scoped to active tab)
|
|
||||||
const context = this.navState.restoreAndClearContext<{ returnUrl: string }>();
|
|
||||||
|
|
||||||
if (context?.returnUrl) {
|
|
||||||
await this.router.navigateByUrl(context.returnUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Simplified Navigation
|
|
||||||
|
|
||||||
Use `navigateWithPreservedContext()` to combine navigation + context preservation:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
async editCustomer() {
|
|
||||||
// Navigate and preserve in one call
|
|
||||||
const { success } = await this.navState.navigateWithPreservedContext(
|
|
||||||
['/customer/search'],
|
|
||||||
{ returnUrl: '/reward/cart', customerId: 123 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core API
|
|
||||||
|
|
||||||
### Context Management
|
|
||||||
|
|
||||||
#### `preserveContext<T>(state, customScope?)`
|
|
||||||
|
|
||||||
Save navigation context that survives intermediate navigations.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Default tab scope
|
|
||||||
navState.preserveContext({
|
|
||||||
returnUrl: '/reward/cart',
|
|
||||||
selectedItems: [1, 2, 3]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Custom scope within tab
|
|
||||||
navState.preserveContext(
|
|
||||||
{ customerId: 42 },
|
|
||||||
'customer-details' // Stored as 'customer-details' in active tab's metadata
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `state`: The data to preserve (any object)
|
|
||||||
- `customScope` (optional): Custom scope within the tab (e.g., `'customer-details'`)
|
|
||||||
|
|
||||||
**Storage Location:**
|
|
||||||
- Stored in active tab's metadata at: `tab.metadata['navigation-contexts'][scopeKey]`
|
|
||||||
- Default scope: `'default'`
|
|
||||||
- Custom scope: `customScope` value
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `patchContext<T>(partialState, customScope?)`
|
|
||||||
|
|
||||||
Partially update preserved context without replacing the entire context.
|
|
||||||
|
|
||||||
This method merges partial state with the existing context, preserving properties you don't specify. Properties explicitly set to `undefined` will be removed from the context.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Existing context: { returnUrl: '/cart', autoTriggerContinueFn: true, customerId: 123 }
|
|
||||||
|
|
||||||
// Clear one property while preserving others
|
|
||||||
await navState.patchContext(
|
|
||||||
{ autoTriggerContinueFn: undefined },
|
|
||||||
'select-customer'
|
|
||||||
);
|
|
||||||
// Result: { returnUrl: '/cart', customerId: 123 }
|
|
||||||
|
|
||||||
// Update one property while preserving others
|
|
||||||
await navState.patchContext(
|
|
||||||
{ customerId: 456 },
|
|
||||||
'select-customer'
|
|
||||||
);
|
|
||||||
// Result: { returnUrl: '/cart', customerId: 456 }
|
|
||||||
|
|
||||||
// Add new property to existing context
|
|
||||||
await navState.patchContext(
|
|
||||||
{ selectedTab: 'details' },
|
|
||||||
'select-customer'
|
|
||||||
);
|
|
||||||
// Result: { returnUrl: '/cart', customerId: 456, selectedTab: 'details' }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `partialState`: Partial state object to merge (set properties to `undefined` to remove them)
|
|
||||||
- `customScope` (optional): Custom scope within the tab
|
|
||||||
|
|
||||||
**Use Cases:**
|
|
||||||
- Clear trigger flags while preserving flow data
|
|
||||||
- Update specific properties without affecting others
|
|
||||||
- Remove properties from context
|
|
||||||
- Add properties to existing context
|
|
||||||
|
|
||||||
**Comparison with `preserveContext`:**
|
|
||||||
- `preserveContext`: Replaces entire context (overwrites all properties)
|
|
||||||
- `patchContext`: Merges with existing context (updates only specified properties)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ preserveContext - must manually preserve existing properties
|
|
||||||
const context = await navState.restoreContext();
|
|
||||||
await navState.preserveContext({
|
|
||||||
returnUrl: context?.returnUrl, // Must specify
|
|
||||||
customerId: context?.customerId, // Must specify
|
|
||||||
autoTriggerContinueFn: undefined, // Clear this
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ patchContext - automatically preserves unspecified properties
|
|
||||||
await navState.patchContext({
|
|
||||||
autoTriggerContinueFn: undefined, // Only specify what changes
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `restoreContext<T>(customScope?)`
|
|
||||||
|
|
||||||
Retrieve preserved context **without** removing it.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Default tab scope
|
|
||||||
const context = navState.restoreContext<{ returnUrl: string }>();
|
|
||||||
if (context?.returnUrl) {
|
|
||||||
console.log('Return URL:', context.returnUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom scope
|
|
||||||
const context = navState.restoreContext<{ customerId: number }>('customer-details');
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `customScope` (optional): Custom scope to retrieve from (defaults to 'default')
|
|
||||||
|
|
||||||
**Returns:** The preserved data, or `null` if not found
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `restoreAndClearContext<T>(customScope?)`
|
|
||||||
|
|
||||||
Retrieve preserved context **and automatically remove** it (recommended for cleanup).
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Default tab scope
|
|
||||||
const context = navState.restoreAndClearContext<{ returnUrl: string }>();
|
|
||||||
if (context?.returnUrl) {
|
|
||||||
await router.navigateByUrl(context.returnUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom scope
|
|
||||||
const context = navState.restoreAndClearContext<{ customerId: number }>('customer-details');
|
|
||||||
```
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `customScope` (optional): Custom scope to retrieve from (defaults to 'default')
|
|
||||||
|
|
||||||
**Returns:** The preserved data, or `null` if not found
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `clearPreservedContext(customScope?)`
|
|
||||||
|
|
||||||
Manually remove a context without retrieving its data.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Clear default tab scope
|
|
||||||
navState.clearPreservedContext();
|
|
||||||
|
|
||||||
// Clear custom scope
|
|
||||||
navState.clearPreservedContext('customer-details');
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### `hasPreservedContext(customScope?)`
|
|
||||||
|
|
||||||
Check if a context exists.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Check default tab scope
|
|
||||||
if (navState.hasPreservedContext()) {
|
|
||||||
const context = navState.restoreContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check custom scope
|
|
||||||
if (navState.hasPreservedContext('customer-details')) {
|
|
||||||
const context = navState.restoreContext('customer-details');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Navigation Helpers
|
|
||||||
|
|
||||||
#### `navigateWithPreservedContext(commands, state, customScope?, extras?)`
|
|
||||||
|
|
||||||
Navigate and preserve context in one call.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const { success } = await navState.navigateWithPreservedContext(
|
|
||||||
['/customer/search'],
|
|
||||||
{ returnUrl: '/reward/cart' },
|
|
||||||
'customer-flow', // optional customScope
|
|
||||||
{ queryParams: { foo: 'bar' } } // optional NavigationExtras
|
|
||||||
);
|
|
||||||
|
|
||||||
// Later...
|
|
||||||
const context = navState.restoreAndClearContext('customer-flow');
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Cleanup Methods
|
|
||||||
|
|
||||||
#### `clearScopeContexts()`
|
|
||||||
|
|
||||||
Clear all contexts for the active tab (both default and custom scopes).
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Clear all contexts for active tab
|
|
||||||
const cleared = this.navState.clearScopeContexts();
|
|
||||||
console.log(`Cleaned up ${cleared} contexts`);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Returns:** Number of contexts cleared
|
|
||||||
|
|
||||||
**Note:** This is typically not needed because contexts are **automatically cleaned up when the tab closes**. Use this only for explicit cleanup during the tab's lifecycle.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage Patterns
|
|
||||||
|
|
||||||
### Pattern 1: Multi-Step Flow with Intermediate Navigations
|
|
||||||
|
|
||||||
**Problem:** You need to return to a page after multiple intermediate navigations.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Component A: Start of flow
|
|
||||||
export class RewardCartComponent {
|
|
||||||
navState = inject(NavigationStateService);
|
|
||||||
router = inject(Router);
|
|
||||||
|
|
||||||
async selectCustomer() {
|
|
||||||
// Preserve returnUrl (auto-scoped to tab)
|
|
||||||
this.navState.preserveContext({
|
|
||||||
returnUrl: '/reward/cart'
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.router.navigate(['/customer/search']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component B: Intermediate navigation
|
|
||||||
export class CustomerSearchComponent {
|
|
||||||
router = inject(Router);
|
|
||||||
|
|
||||||
async viewDetails(customerId: number) {
|
|
||||||
await this.router.navigate(['/customer/details', customerId]);
|
|
||||||
// Context still persists!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component C: Another intermediate navigation
|
|
||||||
export class CustomerDetailsComponent {
|
|
||||||
router = inject(Router);
|
|
||||||
|
|
||||||
async addShippingAddress() {
|
|
||||||
await this.router.navigate(['/add-shipping-address']);
|
|
||||||
// Context still persists!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component D: End of flow
|
|
||||||
export class FinalStepComponent {
|
|
||||||
navState = inject(NavigationStateService);
|
|
||||||
router = inject(Router);
|
|
||||||
|
|
||||||
async complete() {
|
|
||||||
// Restore context (auto-scoped to tab) and navigate back
|
|
||||||
const context = this.navState.restoreAndClearContext<{ returnUrl: string }>();
|
|
||||||
|
|
||||||
if (context?.returnUrl) {
|
|
||||||
await this.router.navigateByUrl(context.returnUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Pattern 2: Multiple Flows in Same Tab
|
|
||||||
|
|
||||||
Use custom scopes to manage different flows within the same tab.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export class ComplexPageComponent {
|
|
||||||
navState = inject(NavigationStateService);
|
|
||||||
|
|
||||||
async startCustomerFlow() {
|
|
||||||
// Store context for customer flow
|
|
||||||
this.navState.preserveContext(
|
|
||||||
{ returnUrl: '/dashboard', step: 1 },
|
|
||||||
'customer-flow'
|
|
||||||
);
|
|
||||||
// Stored in active tab metadata under scope 'customer-flow'
|
|
||||||
}
|
|
||||||
|
|
||||||
async startProductFlow() {
|
|
||||||
// Store context for product flow
|
|
||||||
this.navState.preserveContext(
|
|
||||||
{ returnUrl: '/dashboard', selectedProducts: [1, 2] },
|
|
||||||
'product-flow'
|
|
||||||
);
|
|
||||||
// Stored in active tab metadata under scope 'product-flow'
|
|
||||||
}
|
|
||||||
|
|
||||||
async completeCustomerFlow() {
|
|
||||||
// Restore from customer flow
|
|
||||||
const context = this.navState.restoreAndClearContext('customer-flow');
|
|
||||||
}
|
|
||||||
|
|
||||||
async completeProductFlow() {
|
|
||||||
// Restore from product flow
|
|
||||||
const context = this.navState.restoreAndClearContext('product-flow');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Pattern 3: Complex Context Data
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface CheckoutContext {
|
|
||||||
returnUrl: string;
|
|
||||||
selectedItems: number[];
|
|
||||||
customerId: number;
|
|
||||||
shippingAddressId?: number;
|
|
||||||
metadata: {
|
|
||||||
source: 'reward' | 'checkout';
|
|
||||||
timestamp: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save
|
|
||||||
navState.preserveContext<CheckoutContext>({
|
|
||||||
returnUrl: '/reward/cart',
|
|
||||||
selectedItems: [1, 2, 3],
|
|
||||||
customerId: 456,
|
|
||||||
metadata: {
|
|
||||||
source: 'reward',
|
|
||||||
timestamp: Date.now()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore with type safety
|
|
||||||
const context = navState.restoreAndClearContext<CheckoutContext>();
|
|
||||||
if (context) {
|
|
||||||
console.log('Items:', context.selectedItems);
|
|
||||||
console.log('Customer:', context.customerId);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Pattern 4: No Manual Cleanup Needed
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export class TabAwareComponent {
|
|
||||||
navState = inject(NavigationStateService);
|
|
||||||
|
|
||||||
async startFlow() {
|
|
||||||
// Set context
|
|
||||||
this.navState.preserveContext({ returnUrl: '/home' });
|
|
||||||
|
|
||||||
// No need to clear in ngOnDestroy!
|
|
||||||
// Context is automatically cleaned up when tab closes
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ NOT NEEDED:
|
|
||||||
// ngOnDestroy() {
|
|
||||||
// this.navState.clearScopeContexts();
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph LR
|
|
||||||
A[NavigationStateService] --> B[NavigationContextService]
|
|
||||||
B --> C[TabService]
|
|
||||||
C --> D[Tab Metadata Storage]
|
|
||||||
D --> E[UserStorage Persistence]
|
|
||||||
|
|
||||||
style A fill:#e1f5ff
|
|
||||||
style B fill:#e1f5ff
|
|
||||||
style C fill:#fff4e1
|
|
||||||
style D fill:#e8f5e9
|
|
||||||
style E fill:#f3e5f5
|
|
||||||
```
|
|
||||||
|
|
||||||
1. **Context Storage**: Contexts are stored in **tab metadata** using `TabService`
|
|
||||||
2. **Automatic Scoping**: Active tab ID determines storage location automatically
|
|
||||||
3. **Hierarchical Keys**: Scopes are organized as `tab.metadata['navigation-contexts'][customScope]`
|
|
||||||
4. **Automatic Cleanup**: Contexts removed automatically when tabs close (via tab lifecycle)
|
|
||||||
5. **Persistent Across Refresh**: Tab metadata persists via UserStorage, so contexts survive page refresh
|
|
||||||
6. **Map-Based**: One context per scope for clarity
|
|
||||||
|
|
||||||
### Tab Metadata Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Example: Tab with ID 123
|
|
||||||
tab.metadata = {
|
|
||||||
'navigation-contexts': {
|
|
||||||
'default': {
|
|
||||||
data: { returnUrl: '/cart', selectedItems: [1, 2, 3] },
|
|
||||||
createdAt: 1234567890000
|
|
||||||
},
|
|
||||||
'customer-details': {
|
|
||||||
data: { customerId: 42, step: 2 },
|
|
||||||
createdAt: 1234567891000
|
|
||||||
},
|
|
||||||
'product-flow': {
|
|
||||||
data: { productIds: [100, 200], source: 'recommendation' },
|
|
||||||
createdAt: 1234567892000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// ... other tab metadata
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Storage Layers
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ NavigationStateService (Public API)│
|
|
||||||
└──────────────┬──────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ NavigationContextService (Storage) │
|
|
||||||
└──────────────┬──────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ TabService.patchTabMetadata() │
|
|
||||||
└──────────────┬──────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ Tab Metadata Storage (In-Memory) │
|
|
||||||
└──────────────┬──────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ UserStorage (SessionStorage) │
|
|
||||||
│ (Automatic Persistence) │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Integration with TabService
|
|
||||||
|
|
||||||
This library **requires** `@isa/core/tabs` for automatic tab scoping:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { TabService } from '@isa/core/tabs';
|
|
||||||
|
|
||||||
// NavigationContextService uses:
|
|
||||||
const tabId = this.tabService.activatedTabId(); // Returns: number | null
|
|
||||||
|
|
||||||
if (tabId !== null) {
|
|
||||||
// Store in: tab.metadata['navigation-contexts'][customScope]
|
|
||||||
this.tabService.patchTabMetadata(tabId, {
|
|
||||||
'navigation-contexts': {
|
|
||||||
[customScope]: { data, createdAt }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**When no tab is active** (tabId = null):
|
|
||||||
- Operations throw an error to prevent data loss
|
|
||||||
- This ensures contexts are always properly scoped to a tab
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Guide
|
|
||||||
|
|
||||||
### From SessionStorage to Tab Metadata
|
|
||||||
|
|
||||||
This library previously used SessionStorage for context persistence. It has been refactored to use tab metadata for better integration with the tab lifecycle and automatic cleanup.
|
|
||||||
|
|
||||||
### What Changed
|
|
||||||
|
|
||||||
**Storage Location:**
|
|
||||||
- **Before**: SessionStorage with key `'isa:navigation:context-map'`
|
|
||||||
- **After**: Tab metadata at `tab.metadata['navigation-contexts']`
|
|
||||||
|
|
||||||
**Cleanup:**
|
|
||||||
- **Before**: Manual cleanup required + automatic expiration after 24 hours
|
|
||||||
- **After**: Automatic cleanup when tab closes (no manual cleanup needed)
|
|
||||||
|
|
||||||
**Scope Keys:**
|
|
||||||
- **Before**: `"123"` (tab ID), `"123-customer-details"` (tab ID + custom scope)
|
|
||||||
- **After**: `"default"`, `"customer-details"` (custom scope only, tab ID implicit from storage location)
|
|
||||||
|
|
||||||
**TTL Parameter:**
|
|
||||||
- **Before**: `preserveContext(data, customScope, ttl)` - TTL respected
|
|
||||||
- **After**: `preserveContext(data, customScope, ttl)` - TTL parameter ignored (kept for compatibility)
|
|
||||||
|
|
||||||
### What Stayed the Same
|
|
||||||
|
|
||||||
✅ **Public API**: All public methods remain unchanged
|
|
||||||
✅ **Type Safety**: Full TypeScript support with generics
|
|
||||||
✅ **Hierarchical Scoping**: Custom scopes still work the same way
|
|
||||||
✅ **Usage Patterns**: All existing code continues to work
|
|
||||||
✅ **Persistence**: Contexts still survive page refresh (via TabService UserStorage)
|
|
||||||
|
|
||||||
### Benefits of Tab Metadata Approach
|
|
||||||
|
|
||||||
1. **Automatic Cleanup**: No need to manually clear contexts or worry about stale data
|
|
||||||
2. **Better Integration**: Seamless integration with tab lifecycle management
|
|
||||||
3. **Simpler Mental Model**: Contexts are "owned" by tabs, not global storage
|
|
||||||
4. **No TTL Management**: Tab lifecycle handles cleanup automatically
|
|
||||||
5. **Safer**: Impossible to leak contexts across unrelated tabs
|
|
||||||
|
|
||||||
### Migration Steps
|
|
||||||
|
|
||||||
**No action required!** The public API is unchanged. Your existing code will continue to work:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ This code works exactly the same before and after migration
|
|
||||||
navState.preserveContext({ returnUrl: '/cart' });
|
|
||||||
const context = navState.restoreAndClearContext<{ returnUrl: string }>();
|
|
||||||
```
|
|
||||||
|
|
||||||
**Optional: Remove manual cleanup code**
|
|
||||||
|
|
||||||
If you have manual cleanup in `ngOnDestroy`, you can safely remove it:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Before (still works, but unnecessary):
|
|
||||||
ngOnDestroy() {
|
|
||||||
this.navState.clearScopeContexts();
|
|
||||||
}
|
|
||||||
|
|
||||||
// After (automatic cleanup):
|
|
||||||
ngOnDestroy() {
|
|
||||||
// No cleanup needed - tab lifecycle handles it!
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note on TTL parameter**
|
|
||||||
|
|
||||||
If you were using the TTL parameter, be aware it's now ignored:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Before: TTL respected
|
|
||||||
navState.preserveContext({ data: 'foo' }, undefined, 60000); // Expires in 1 minute
|
|
||||||
|
|
||||||
// After: TTL ignored (context lives until tab closes)
|
|
||||||
navState.preserveContext({ data: 'foo' }, undefined, 60000); // Ignored parameter
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Mocking NavigationStateService
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
import { NavigationStateService } from '@isa/core/navigation';
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
||||||
|
|
||||||
describe('MyComponent', () => {
|
|
||||||
let component: MyComponent;
|
|
||||||
let fixture: ComponentFixture<MyComponent>;
|
|
||||||
let navStateMock: any;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
navStateMock = {
|
|
||||||
preserveContext: vi.fn(),
|
|
||||||
restoreContext: vi.fn().mockReturnValue({ returnUrl: '/test' }),
|
|
||||||
restoreAndClearContext: vi.fn().mockReturnValue({ returnUrl: '/test' }),
|
|
||||||
clearPreservedContext: vi.fn().mockReturnValue(true),
|
|
||||||
hasPreservedContext: vi.fn().mockReturnValue(true),
|
|
||||||
};
|
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [MyComponent],
|
|
||||||
providers: [
|
|
||||||
{ provide: NavigationStateService, useValue: navStateMock }
|
|
||||||
]
|
|
||||||
}).compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(MyComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve context when navigating', async () => {
|
|
||||||
await component.startFlow();
|
|
||||||
|
|
||||||
expect(navStateMock.preserveContext).toHaveBeenCalledWith({
|
|
||||||
returnUrl: '/reward/cart'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should restore context and navigate back', async () => {
|
|
||||||
navStateMock.restoreAndClearContext.mockReturnValue({ returnUrl: '/cart' });
|
|
||||||
|
|
||||||
await component.complete();
|
|
||||||
|
|
||||||
expect(navStateMock.restoreAndClearContext).toHaveBeenCalled();
|
|
||||||
// Assert navigation occurred
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### ✅ Do
|
|
||||||
|
|
||||||
- **Use `restoreAndClearContext()`** for automatic cleanup when completing flows
|
|
||||||
- **Use custom scopes** for multiple concurrent flows in the same tab
|
|
||||||
- **Leverage type safety** with TypeScript generics (`<T>`)
|
|
||||||
- **Trust automatic cleanup** - no need to manually clear contexts when tabs close
|
|
||||||
- **Check for null** when restoring contexts (they may not exist)
|
|
||||||
|
|
||||||
### ❌ Don't
|
|
||||||
|
|
||||||
- **Don't store large objects** - keep contexts lean (return URLs, IDs, simple flags)
|
|
||||||
- **Don't use for persistent data** - use NgRx or services for long-lived state
|
|
||||||
- **Don't rely on TTL** - the TTL parameter is ignored in the current implementation
|
|
||||||
- **Don't manually clear in ngOnDestroy** - tab lifecycle handles it automatically
|
|
||||||
- **Don't store sensitive data** - contexts may be visible in browser dev tools
|
|
||||||
|
|
||||||
### When to Use Navigation Context
|
|
||||||
|
|
||||||
✅ **Good Use Cases:**
|
|
||||||
- Return URLs for multi-step flows
|
|
||||||
- Wizard/multi-step form state
|
|
||||||
- Temporary search filters or selections
|
|
||||||
- Flow-specific context (customer ID during checkout)
|
|
||||||
|
|
||||||
❌ **Bad Use Cases:**
|
|
||||||
- User preferences (use NgRx or services)
|
|
||||||
- Authentication tokens (use dedicated auth service)
|
|
||||||
- Large datasets (use data services with caching)
|
|
||||||
- Cross-tab communication (use BroadcastChannel or shared services)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Constants
|
|
||||||
|
|
||||||
All configuration is in `navigation-context.constants.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Metadata key for storing contexts in tab metadata
|
|
||||||
export const NAVIGATION_CONTEXT_METADATA_KEY = 'navigation-contexts';
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** Previous SessionStorage constants (`DEFAULT_CONTEXT_TTL`, `CLEANUP_INTERVAL`, `NAVIGATION_CONTEXT_STORAGE_KEY`) have been removed as they are no longer needed with tab metadata storage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Reference Summary
|
|
||||||
|
|
||||||
| Method | Parameters | Returns | Purpose |
|
|
||||||
|--------|-----------|---------|---------|
|
|
||||||
| `preserveContext(state, customScope?)` | state: T, customScope?: string | void | Save context |
|
|
||||||
| `patchContext(partialState, customScope?)` | partialState: Partial<T>, customScope?: string | void | Merge partial updates |
|
|
||||||
| `restoreContext(customScope?)` | customScope?: string | T \| null | Get context (keep) |
|
|
||||||
| `restoreAndClearContext(customScope?)` | customScope?: string | T \| null | Get + remove |
|
|
||||||
| `clearPreservedContext(customScope?)` | customScope?: string | boolean | Remove context |
|
|
||||||
| `hasPreservedContext(customScope?)` | customScope?: string | boolean | Check exists |
|
|
||||||
| `navigateWithPreservedContext(...)` | commands, state, customScope?, extras? | Promise<{success}> | Navigate + preserve |
|
|
||||||
| `clearScopeContexts()` | none | number | Bulk cleanup (rarely needed) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Context Not Found After Refresh
|
|
||||||
|
|
||||||
**Problem**: Context is `null` after page refresh.
|
|
||||||
|
|
||||||
**Solution**: Ensure `TabService` is properly initialized and the tab ID is restored from UserStorage. Contexts rely on tab metadata which persists via UserStorage.
|
|
||||||
|
|
||||||
### Context Cleared Unexpectedly
|
|
||||||
|
|
||||||
**Problem**: Context disappears before you retrieve it.
|
|
||||||
|
|
||||||
**Solution**: Check if you're using `restoreAndClearContext()` multiple times. This method removes the context after retrieval. Use `restoreContext()` if you need to access it multiple times.
|
|
||||||
|
|
||||||
### "No active tab" Error
|
|
||||||
|
|
||||||
**Problem**: Getting error "No active tab - cannot set navigation context".
|
|
||||||
|
|
||||||
**Solution**: Ensure `TabService` has an active tab before using navigation context. This typically happens during app initialization before tabs are ready.
|
|
||||||
|
|
||||||
### Context Not Isolated Between Tabs
|
|
||||||
|
|
||||||
**Problem**: Contexts from one tab appearing in another.
|
|
||||||
|
|
||||||
**Solution**: This should not happen with tab metadata storage. If you see this, it may indicate a TabService issue. Check that `TabService.activatedTabId()` returns the correct tab ID.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run tests
|
|
||||||
npx nx test core-navigation
|
|
||||||
|
|
||||||
# Run tests with coverage
|
|
||||||
npx nx test core-navigation --coverage.enabled=true
|
|
||||||
|
|
||||||
# Run tests without cache (CI)
|
|
||||||
npx nx test core-navigation --skip-cache
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test Results:**
|
|
||||||
- 79 tests passing
|
|
||||||
- 2 test files (navigation-state.service.spec.ts, navigation-context.service.spec.ts)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CI/CD Integration
|
|
||||||
|
|
||||||
This library generates JUnit and Cobertura reports for Azure Pipelines:
|
|
||||||
|
|
||||||
- **JUnit Report**: `testresults/junit-core-navigation.xml`
|
|
||||||
- **Cobertura Report**: `coverage/libs/core/navigation/cobertura-coverage.xml`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
This library follows the ISA Frontend monorepo conventions:
|
|
||||||
|
|
||||||
- **Path Alias**: `@isa/core/navigation`
|
|
||||||
- **Testing Framework**: Vitest with Angular Testing Utilities
|
|
||||||
- **Code Style**: ESLint + Prettier
|
|
||||||
- **Test Coverage**: Required for all public APIs
|
|
||||||
- **Dependencies**: Requires `@isa/core/tabs` for tab scoping
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Internal ISA Frontend monorepo library.
|
|
||||||
@@ -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: 'core',
|
|
||||||
style: 'camelCase',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'@angular-eslint/component-selector': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
type: 'element',
|
|
||||||
prefix: 'core',
|
|
||||||
style: 'kebab-case',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['**/*.html'],
|
|
||||||
// Override or add rules here
|
|
||||||
rules: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "core-navigation",
|
|
||||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
|
||||||
"sourceRoot": "libs/core/navigation/src",
|
|
||||||
"prefix": "core",
|
|
||||||
"projectType": "library",
|
|
||||||
"tags": ["skip:ci"],
|
|
||||||
"targets": {
|
|
||||||
"test": {
|
|
||||||
"executor": "@nx/vite:test",
|
|
||||||
"outputs": ["{options.reportsDirectory}"],
|
|
||||||
"options": {
|
|
||||||
"reportsDirectory": "../../../coverage/libs/core/navigation"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"executor": "@nx/eslint:lint"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from './lib/navigation-state.types';
|
|
||||||
export * from './lib/navigation-state.service';
|
|
||||||
export * from './lib/navigation-context.types';
|
|
||||||
export * from './lib/navigation-context.service';
|
|
||||||
export * from './lib/navigation-context.constants';
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Constants for navigation context storage in tab metadata.
|
|
||||||
* Navigation contexts are stored directly in tab metadata instead of sessionStorage,
|
|
||||||
* providing automatic cleanup when tabs are closed and better integration with the tab system.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Key used to store navigation contexts in tab metadata.
|
|
||||||
* Contexts are stored as: tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY][customScope]
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Structure in tab metadata:
|
|
||||||
* tab.metadata = {
|
|
||||||
* 'navigation-contexts': {
|
|
||||||
* 'default': { data: { returnUrl: '/cart' }, createdAt: 123 },
|
|
||||||
* 'customer-details': { data: { customerId: 42 }, createdAt: 456 }
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const NAVIGATION_CONTEXT_METADATA_KEY = 'navigation-contexts';
|
|
||||||
@@ -1,668 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
||||||
import { signal } from '@angular/core';
|
|
||||||
import { NavigationContextService } from './navigation-context.service';
|
|
||||||
import { TabService } from '@isa/core/tabs';
|
|
||||||
import { ReturnUrlContext } from './navigation-context.types';
|
|
||||||
import { NAVIGATION_CONTEXT_METADATA_KEY } from './navigation-context.constants';
|
|
||||||
|
|
||||||
describe('NavigationContextService', () => {
|
|
||||||
let service: NavigationContextService;
|
|
||||||
let tabServiceMock: {
|
|
||||||
activatedTabId: ReturnType<typeof signal<number | null>>;
|
|
||||||
entityMap: ReturnType<typeof vi.fn>;
|
|
||||||
patchTabMetadata: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Create mock TabService with signals and methods
|
|
||||||
tabServiceMock = {
|
|
||||||
activatedTabId: signal<number | null>(null),
|
|
||||||
entityMap: vi.fn(),
|
|
||||||
patchTabMetadata: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [
|
|
||||||
NavigationContextService,
|
|
||||||
{ provide: TabService, useValue: tabServiceMock },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
service = TestBed.inject(NavigationContextService);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be created', () => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setContext', () => {
|
|
||||||
it('should set context in tab metadata', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data: ReturnUrlContext = { returnUrl: '/test-page' };
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await service.setContext(data);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
|
|
||||||
tabId,
|
|
||||||
expect.objectContaining({
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
|
|
||||||
default: expect.objectContaining({
|
|
||||||
data,
|
|
||||||
createdAt: expect.any(Number),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set context with custom scope', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
const customScope = 'customer-details';
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = { customerId: 42 };
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await service.setContext(data, customScope);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
|
|
||||||
tabId,
|
|
||||||
expect.objectContaining({
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
|
|
||||||
[customScope]: expect.objectContaining({
|
|
||||||
data,
|
|
||||||
createdAt: expect.any(Number),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error when no active tab', async () => {
|
|
||||||
// Arrange
|
|
||||||
tabServiceMock.activatedTabId.set(null);
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
await expect(service.setContext({ returnUrl: '/test' })).rejects.toThrow(
|
|
||||||
'No active tab - cannot set navigation context',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should merge with existing contexts', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
|
|
||||||
const existingContexts = {
|
|
||||||
'existing-scope': {
|
|
||||||
data: { existingData: 'value' },
|
|
||||||
createdAt: Date.now() - 1000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: existingContexts,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const newData = { returnUrl: '/new-page' };
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await service.setContext(newData);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
|
|
||||||
tabId,
|
|
||||||
expect.objectContaining({
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
|
|
||||||
'existing-scope': existingContexts['existing-scope'],
|
|
||||||
default: expect.objectContaining({
|
|
||||||
data: newData,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept TTL parameter for backward compatibility', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await service.setContext({ returnUrl: '/test' }, undefined, 60000);
|
|
||||||
|
|
||||||
// Assert - TTL is ignored but method should still work
|
|
||||||
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getContext', () => {
|
|
||||||
it('should return null when no active tab', async () => {
|
|
||||||
// Arrange
|
|
||||||
tabServiceMock.activatedTabId.set(null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.getContext();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when context does not exist', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.getContext();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should retrieve context from default scope', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
const data: ReturnUrlContext = { returnUrl: '/test-page' };
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {
|
|
||||||
default: {
|
|
||||||
data,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.getContext<ReturnUrlContext>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toEqual(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should retrieve context from custom scope', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
const customScope = 'checkout-flow';
|
|
||||||
const data = { step: 2, productId: 456 };
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {
|
|
||||||
[customScope]: {
|
|
||||||
data,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.getContext(customScope);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toEqual(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when tab not found', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.getContext();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle invalid metadata gracefully', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: 'invalid', // Invalid type
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.getContext();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAndClearContext', () => {
|
|
||||||
it('should return null when no active tab', async () => {
|
|
||||||
// Arrange
|
|
||||||
tabServiceMock.activatedTabId.set(null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.getAndClearContext();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should retrieve and remove context from default scope', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
const data: ReturnUrlContext = { returnUrl: '/test-page' };
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {
|
|
||||||
default: {
|
|
||||||
data,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.getAndClearContext<ReturnUrlContext>();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toEqual(data);
|
|
||||||
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should retrieve and remove context from custom scope', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
const customScope = 'wizard-flow';
|
|
||||||
const data = { currentStep: 3 };
|
|
||||||
const otherScopeData = { otherData: 'value' };
|
|
||||||
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {
|
|
||||||
[customScope]: {
|
|
||||||
data,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
},
|
|
||||||
'other-scope': {
|
|
||||||
data: otherScopeData,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.getAndClearContext(customScope);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toEqual(data);
|
|
||||||
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {
|
|
||||||
'other-scope': expect.objectContaining({
|
|
||||||
data: otherScopeData,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when context not found', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.getAndClearContext();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(tabServiceMock.patchTabMetadata).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('clearContext', () => {
|
|
||||||
it('should return true when context exists and is cleared', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
const data = { returnUrl: '/test' };
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {
|
|
||||||
default: { data, createdAt: Date.now() },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.clearContext();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when context not found', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.clearContext();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('clearScope', () => {
|
|
||||||
it('should clear all contexts for active tab', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
const contexts = {
|
|
||||||
default: { data: { url: '/test' }, createdAt: Date.now() },
|
|
||||||
'scope-1': { data: { value: 1 }, createdAt: Date.now() },
|
|
||||||
'scope-2': { data: { value: 2 }, createdAt: Date.now() },
|
|
||||||
};
|
|
||||||
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const clearedCount = await service.clearScope();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(clearedCount).toBe(3);
|
|
||||||
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 when no contexts exist', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const clearedCount = await service.clearScope();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(clearedCount).toBe(0);
|
|
||||||
expect(tabServiceMock.patchTabMetadata).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 when no active tab', async () => {
|
|
||||||
// Arrange
|
|
||||||
tabServiceMock.activatedTabId.set(null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const clearedCount = await service.clearScope();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(clearedCount).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('clearAll', () => {
|
|
||||||
it('should clear all contexts for active tab', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
const contexts = {
|
|
||||||
default: { data: { url: '/test' }, createdAt: Date.now() },
|
|
||||||
};
|
|
||||||
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await service.clearAll();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hasContext', () => {
|
|
||||||
it('should return true when context exists', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
const data = { returnUrl: '/test' };
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {
|
|
||||||
default: { data, createdAt: Date.now() },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.hasContext();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when context does not exist', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.hasContext();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should check custom scope', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
const customScope = 'wizard';
|
|
||||||
const data = { step: 1 };
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {
|
|
||||||
[customScope]: { data, createdAt: Date.now() },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const result = await service.hasContext(customScope);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getContextCount', () => {
|
|
||||||
it('should return total number of contexts for active tab', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
const contexts = {
|
|
||||||
default: { data: { url: '/test' }, createdAt: Date.now() },
|
|
||||||
'scope-1': { data: { value: 1 }, createdAt: Date.now() },
|
|
||||||
'scope-2': { data: { value: 2 }, createdAt: Date.now() },
|
|
||||||
};
|
|
||||||
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const count = await service.getContextCount();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(count).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 when no contexts exist', async () => {
|
|
||||||
// Arrange
|
|
||||||
const tabId = 123;
|
|
||||||
tabServiceMock.activatedTabId.set(tabId);
|
|
||||||
tabServiceMock.entityMap.mockReturnValue({
|
|
||||||
[tabId]: {
|
|
||||||
id: tabId,
|
|
||||||
name: 'Test Tab',
|
|
||||||
metadata: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const count = await service.getContextCount();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(count).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 when no active tab', async () => {
|
|
||||||
// Arrange
|
|
||||||
tabServiceMock.activatedTabId.set(null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const count = await service.getContextCount();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(count).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { TabService } from '@isa/core/tabs';
|
|
||||||
import { logger } from '@isa/core/logging';
|
|
||||||
import {
|
|
||||||
NavigationContext,
|
|
||||||
NavigationContextData,
|
|
||||||
NavigationContextsMetadataSchema,
|
|
||||||
} from './navigation-context.types';
|
|
||||||
import { NAVIGATION_CONTEXT_METADATA_KEY } from './navigation-context.constants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for managing navigation context using tab metadata storage.
|
|
||||||
*
|
|
||||||
* This service provides a type-safe approach to preserving navigation state
|
|
||||||
* across intermediate navigations, solving the problem of lost router state
|
|
||||||
* in multi-step flows.
|
|
||||||
*
|
|
||||||
* Key Features:
|
|
||||||
* - Stores contexts in tab metadata (automatic cleanup when tab closes)
|
|
||||||
* - Type-safe with Zod validation
|
|
||||||
* - Scoped to individual tabs (no cross-tab pollution)
|
|
||||||
* - Simple API with hierarchical scoping support
|
|
||||||
*
|
|
||||||
* Storage Architecture:
|
|
||||||
* - Contexts stored at: `tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY]`
|
|
||||||
* - Structure: `{ [customScope]: { data, createdAt } }`
|
|
||||||
* - No manual cleanup needed (handled by tab lifecycle)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Start of flow - preserve context (auto-scoped to active tab)
|
|
||||||
* contextService.setContext({
|
|
||||||
* returnUrl: '/original-page',
|
|
||||||
* customerId: 123
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // ... intermediate navigations happen ...
|
|
||||||
*
|
|
||||||
* // End of flow - restore and cleanup
|
|
||||||
* const context = contextService.getAndClearContext<{ returnUrl: string }>();
|
|
||||||
* if (context?.returnUrl) {
|
|
||||||
* await router.navigateByUrl(context.returnUrl);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class NavigationContextService {
|
|
||||||
readonly #tabService = inject(TabService);
|
|
||||||
readonly #log = logger(() => ({ module: 'navigation-context' }));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the navigation contexts map from tab metadata.
|
|
||||||
*
|
|
||||||
* @param tabId The tab ID to get contexts for
|
|
||||||
* @returns Record of scope keys to contexts, or empty object if not found
|
|
||||||
*/
|
|
||||||
#getContextsMap(tabId: number): Record<string, NavigationContext> {
|
|
||||||
const tab = this.#tabService.entityMap()[tabId];
|
|
||||||
if (!tab) {
|
|
||||||
this.#log.debug('Tab not found', () => ({ tabId }));
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextsMap = tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY];
|
|
||||||
if (!contextsMap) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate with Zod schema
|
|
||||||
const result = NavigationContextsMetadataSchema.safeParse(contextsMap);
|
|
||||||
if (!result.success) {
|
|
||||||
this.#log.warn('Invalid contexts map in tab metadata', () => ({
|
|
||||||
tabId,
|
|
||||||
validationErrors: result.error.errors,
|
|
||||||
}));
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data as Record<string, NavigationContext>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save the navigation contexts map to tab metadata.
|
|
||||||
*
|
|
||||||
* @param tabId The tab ID to save contexts to
|
|
||||||
* @param contextsMap The contexts map to save
|
|
||||||
*/
|
|
||||||
#saveContextsMap(
|
|
||||||
tabId: number,
|
|
||||||
contextsMap: Record<string, NavigationContext>,
|
|
||||||
): void {
|
|
||||||
this.#tabService.patchTabMetadata(tabId, {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: contextsMap,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a context in the active tab's metadata.
|
|
||||||
*
|
|
||||||
* Creates or overwrites a navigation context and persists it to tab metadata.
|
|
||||||
* The context will automatically be cleaned up when the tab is closed.
|
|
||||||
*
|
|
||||||
* @template T The type of data being stored in the context
|
|
||||||
* @param data The navigation data to preserve
|
|
||||||
* @param customScope Optional custom scope (defaults to 'default')
|
|
||||||
* @param _ttl Optional TTL parameter (kept for API compatibility but ignored)
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Set context for default scope
|
|
||||||
* contextService.setContext({ returnUrl: '/products', selectedIds: [1, 2, 3] });
|
|
||||||
*
|
|
||||||
* // Set context for custom scope
|
|
||||||
* contextService.setContext({ customerId: 42 }, 'customer-details');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async setContext<T extends NavigationContextData>(
|
|
||||||
data: T,
|
|
||||||
customScope?: string,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
_ttl?: number, // Kept for API compatibility but ignored
|
|
||||||
): Promise<void> {
|
|
||||||
const tabId = this.#tabService.activatedTabId();
|
|
||||||
if (tabId === null) {
|
|
||||||
throw new Error('No active tab - cannot set navigation context');
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeKey = customScope || 'default';
|
|
||||||
const context: NavigationContext = {
|
|
||||||
data,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextsMap = this.#getContextsMap(tabId);
|
|
||||||
contextsMap[scopeKey] = context;
|
|
||||||
this.#saveContextsMap(tabId, contextsMap);
|
|
||||||
|
|
||||||
this.#log.debug('Context set in tab metadata', () => ({
|
|
||||||
tabId,
|
|
||||||
scopeKey,
|
|
||||||
dataKeys: Object.keys(data),
|
|
||||||
totalContexts: Object.keys(contextsMap).length,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patch a context in the active tab's metadata.
|
|
||||||
*
|
|
||||||
* Merges partial data with the existing context, preserving unspecified properties.
|
|
||||||
* Properties explicitly set to `undefined` will be removed from the context.
|
|
||||||
* If the context doesn't exist, creates a new one (behaves like setContext).
|
|
||||||
*
|
|
||||||
* @template T The type of data being patched
|
|
||||||
* @param partialData The partial navigation data to merge
|
|
||||||
* @param customScope Optional custom scope (defaults to 'default')
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Clear one property while preserving others
|
|
||||||
* contextService.patchContext({ autoTriggerContinueFn: undefined }, 'select-customer');
|
|
||||||
*
|
|
||||||
* // Update one property while preserving others
|
|
||||||
* contextService.patchContext({ selectedTab: 'details' }, 'customer-flow');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async patchContext<T extends NavigationContextData>(
|
|
||||||
partialData: Partial<T>,
|
|
||||||
customScope?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const tabId = this.#tabService.activatedTabId();
|
|
||||||
if (tabId === null) {
|
|
||||||
throw new Error('No active tab - cannot patch navigation context');
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeKey = customScope || 'default';
|
|
||||||
const existingContext = await this.getContext<T>(customScope);
|
|
||||||
|
|
||||||
const mergedData = {
|
|
||||||
...(existingContext ?? {}),
|
|
||||||
...partialData,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove properties explicitly set to undefined
|
|
||||||
const removedKeys: string[] = [];
|
|
||||||
Object.keys(mergedData).forEach((key) => {
|
|
||||||
if (mergedData[key] === undefined) {
|
|
||||||
removedKeys.push(key);
|
|
||||||
delete mergedData[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const contextsMap = this.#getContextsMap(tabId);
|
|
||||||
const context: NavigationContext = {
|
|
||||||
data: mergedData,
|
|
||||||
createdAt:
|
|
||||||
existingContext && contextsMap[scopeKey]
|
|
||||||
? contextsMap[scopeKey].createdAt
|
|
||||||
: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
contextsMap[scopeKey] = context;
|
|
||||||
this.#saveContextsMap(tabId, contextsMap);
|
|
||||||
|
|
||||||
this.#log.debug('Context patched in tab metadata', () => ({
|
|
||||||
tabId,
|
|
||||||
scopeKey,
|
|
||||||
patchedKeys: Object.keys(partialData),
|
|
||||||
removedKeys,
|
|
||||||
totalDataKeys: Object.keys(mergedData),
|
|
||||||
wasUpdate: existingContext !== null,
|
|
||||||
totalContexts: Object.keys(contextsMap).length,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a context from the active tab's metadata without removing it.
|
|
||||||
*
|
|
||||||
* Retrieves a preserved navigation context by scope.
|
|
||||||
*
|
|
||||||
* @template T The expected type of the context data
|
|
||||||
* @param customScope Optional custom scope (defaults to 'default')
|
|
||||||
* @returns The context data, or null if not found
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Get context for default scope
|
|
||||||
* const context = contextService.getContext<{ returnUrl: string }>();
|
|
||||||
*
|
|
||||||
* // Get context for custom scope
|
|
||||||
* const context = contextService.getContext<{ customerId: number }>('customer-details');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async getContext<T extends NavigationContextData = NavigationContextData>(
|
|
||||||
customScope?: string,
|
|
||||||
): Promise<T | null> {
|
|
||||||
const tabId = this.#tabService.activatedTabId();
|
|
||||||
if (tabId === null) {
|
|
||||||
this.#log.debug('No active tab - cannot get context');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeKey = customScope || 'default';
|
|
||||||
const contextsMap = this.#getContextsMap(tabId);
|
|
||||||
const context = contextsMap[scopeKey];
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
this.#log.debug('Context not found', () => ({ tabId, scopeKey }));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#log.debug('Context retrieved', () => ({
|
|
||||||
tabId,
|
|
||||||
scopeKey,
|
|
||||||
dataKeys: Object.keys(context.data),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return context.data as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a context from the active tab's metadata and remove it.
|
|
||||||
*
|
|
||||||
* Retrieves a preserved navigation context and removes it from the metadata.
|
|
||||||
* Use this when completing a flow to clean up automatically.
|
|
||||||
*
|
|
||||||
* @template T The expected type of the context data
|
|
||||||
* @param customScope Optional custom scope (defaults to 'default')
|
|
||||||
* @returns The context data, or null if not found
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Get and clear context for default scope
|
|
||||||
* const context = contextService.getAndClearContext<{ returnUrl: string }>();
|
|
||||||
* if (context?.returnUrl) {
|
|
||||||
* await router.navigateByUrl(context.returnUrl);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // Get and clear context for custom scope
|
|
||||||
* const context = contextService.getAndClearContext<{ customerId: number }>('customer-details');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async getAndClearContext<
|
|
||||||
T extends NavigationContextData = NavigationContextData,
|
|
||||||
>(customScope?: string): Promise<T | null> {
|
|
||||||
const tabId = this.#tabService.activatedTabId();
|
|
||||||
if (tabId === null) {
|
|
||||||
this.#log.debug('No active tab - cannot get and clear context');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeKey = customScope || 'default';
|
|
||||||
const contextsMap = this.#getContextsMap(tabId);
|
|
||||||
const context = contextsMap[scopeKey];
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
this.#log.debug('Context not found for clearing', () => ({
|
|
||||||
tabId,
|
|
||||||
scopeKey,
|
|
||||||
}));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from map
|
|
||||||
delete contextsMap[scopeKey];
|
|
||||||
this.#saveContextsMap(tabId, contextsMap);
|
|
||||||
|
|
||||||
this.#log.debug('Context retrieved and cleared', () => ({
|
|
||||||
tabId,
|
|
||||||
scopeKey,
|
|
||||||
dataKeys: Object.keys(context.data),
|
|
||||||
remainingContexts: Object.keys(contextsMap).length,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return context.data as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear a specific context from the active tab's metadata.
|
|
||||||
*
|
|
||||||
* Removes a context without returning its data.
|
|
||||||
* Useful for explicit cleanup without needing the data.
|
|
||||||
*
|
|
||||||
* @param customScope Optional custom scope (defaults to 'default')
|
|
||||||
* @returns true if context was found and cleared, false otherwise
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Clear context for default scope
|
|
||||||
* contextService.clearContext();
|
|
||||||
*
|
|
||||||
* // Clear context for custom scope
|
|
||||||
* contextService.clearContext('customer-details');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async clearContext(customScope?: string): Promise<boolean> {
|
|
||||||
const result = await this.getAndClearContext(customScope);
|
|
||||||
return result !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all contexts for the active tab.
|
|
||||||
*
|
|
||||||
* Removes all contexts from the active tab's metadata.
|
|
||||||
* Useful for cleanup when a workflow is cancelled or completed.
|
|
||||||
*
|
|
||||||
* @returns The number of contexts cleared
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Clear all contexts for active tab
|
|
||||||
* const cleared = contextService.clearScope();
|
|
||||||
* console.log(`Cleared ${cleared} contexts`);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async clearScope(): Promise<number> {
|
|
||||||
const tabId = this.#tabService.activatedTabId();
|
|
||||||
if (tabId === null) {
|
|
||||||
this.#log.warn('Cannot clear scope: no active tab');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextsMap = this.#getContextsMap(tabId);
|
|
||||||
const contextCount = Object.keys(contextsMap).length;
|
|
||||||
|
|
||||||
if (contextCount === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear entire metadata key
|
|
||||||
this.#tabService.patchTabMetadata(tabId, {
|
|
||||||
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.#log.debug('Tab scope cleared', () => ({
|
|
||||||
tabId,
|
|
||||||
clearedCount: contextCount,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return contextCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all contexts from the active tab (alias for clearScope).
|
|
||||||
*
|
|
||||||
* This method is kept for backward compatibility with the previous API.
|
|
||||||
* It clears all contexts for the active tab only, not globally.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* contextService.clearAll();
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async clearAll(): Promise<void> {
|
|
||||||
await this.clearScope();
|
|
||||||
this.#log.debug('All contexts cleared for active tab');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a context exists for the active tab.
|
|
||||||
*
|
|
||||||
* @param customScope Optional custom scope (defaults to 'default')
|
|
||||||
* @returns true if context exists, false otherwise
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Check default scope
|
|
||||||
* if (contextService.hasContext()) {
|
|
||||||
* const context = contextService.getContext();
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // Check custom scope
|
|
||||||
* if (contextService.hasContext('customer-details')) {
|
|
||||||
* const context = contextService.getContext('customer-details');
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async hasContext(customScope?: string): Promise<boolean> {
|
|
||||||
const context = await this.getContext(customScope);
|
|
||||||
return context !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current context count for the active tab (for debugging/monitoring).
|
|
||||||
*
|
|
||||||
* @returns The total number of contexts in the active tab's metadata
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const count = await contextService.getContextCount();
|
|
||||||
* console.log(`Active tab has ${count} contexts`);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async getContextCount(): Promise<number> {
|
|
||||||
const tabId = this.#tabService.activatedTabId();
|
|
||||||
if (tabId === null) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextsMap = this.#getContextsMap(tabId);
|
|
||||||
return Object.keys(contextsMap).length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base interface for navigation context data.
|
|
||||||
* Extend this interface for type-safe context preservation.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* interface MyFlowContext extends NavigationContextData {
|
|
||||||
* returnUrl: string;
|
|
||||||
* selectedItems: number[];
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export interface NavigationContextData {
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigation context stored in tab metadata.
|
|
||||||
* Represents a single preserved navigation state with metadata.
|
|
||||||
* Stored at: tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY][customScope]
|
|
||||||
*/
|
|
||||||
export interface NavigationContext {
|
|
||||||
/** The preserved navigation state/data */
|
|
||||||
data: NavigationContextData;
|
|
||||||
/** Timestamp when context was created (for debugging and monitoring) */
|
|
||||||
createdAt: number;
|
|
||||||
/**
|
|
||||||
* Optional expiration timestamp (reserved for future TTL implementation)
|
|
||||||
* @deprecated Currently unused - contexts are cleaned up automatically when tabs close
|
|
||||||
*/
|
|
||||||
expiresAt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zod schema for navigation context data validation.
|
|
||||||
*/
|
|
||||||
export const NavigationContextDataSchema = z.record(z.string(), z.unknown());
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zod schema for navigation context validation.
|
|
||||||
*/
|
|
||||||
export const NavigationContextSchema = z.object({
|
|
||||||
data: NavigationContextDataSchema,
|
|
||||||
createdAt: z.number().positive(),
|
|
||||||
expiresAt: z.number().positive().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zod schema for navigation contexts stored in tab metadata.
|
|
||||||
* Structure: { [customScope: string]: NavigationContext }
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* {
|
|
||||||
* "default": { data: { returnUrl: '/cart' }, createdAt: 123, expiresAt: 456 },
|
|
||||||
* "customer-details": { data: { customerId: 42 }, createdAt: 123 }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const NavigationContextsMetadataSchema = z.record(
|
|
||||||
z.string(),
|
|
||||||
NavigationContextSchema
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Common navigation context for "return URL" pattern.
|
|
||||||
* Used when navigating through a flow and needing to return to the original location.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* navContextService.preserveContext({
|
|
||||||
* returnUrl: '/original-page'
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export interface ReturnUrlContext extends NavigationContextData {
|
|
||||||
returnUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extended context with additional flow metadata.
|
|
||||||
* Useful for complex multi-step flows that need to preserve additional state.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* interface CheckoutFlowContext extends FlowContext {
|
|
||||||
* returnUrl: string;
|
|
||||||
* selectedProductIds: number[];
|
|
||||||
* shippingAddressId?: number;
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export interface FlowContext extends NavigationContextData {
|
|
||||||
/** Step identifier for multi-step flows */
|
|
||||||
currentStep?: string;
|
|
||||||
/** Total number of steps (if known) */
|
|
||||||
totalSteps?: number;
|
|
||||||
/** Flow-specific metadata */
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { Location } from '@angular/common';
|
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
||||||
import { NavigationStateService } from './navigation-state.service';
|
|
||||||
import { NavigationContextService } from './navigation-context.service';
|
|
||||||
import { ReturnUrlContext } from './navigation-context.types';
|
|
||||||
|
|
||||||
describe('NavigationStateService', () => {
|
|
||||||
let service: NavigationStateService;
|
|
||||||
let locationMock: { getState: ReturnType<typeof vi.fn> };
|
|
||||||
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
|
||||||
let contextServiceMock: {
|
|
||||||
setContext: ReturnType<typeof vi.fn>;
|
|
||||||
getContext: ReturnType<typeof vi.fn>;
|
|
||||||
getAndClearContext: ReturnType<typeof vi.fn>;
|
|
||||||
clearContext: ReturnType<typeof vi.fn>;
|
|
||||||
hasContext: ReturnType<typeof vi.fn>;
|
|
||||||
clearScope: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
locationMock = {
|
|
||||||
getState: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
routerMock = {
|
|
||||||
navigate: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
contextServiceMock = {
|
|
||||||
setContext: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getContext: vi.fn().mockResolvedValue(null),
|
|
||||||
getAndClearContext: vi.fn().mockResolvedValue(null),
|
|
||||||
clearContext: vi.fn().mockResolvedValue(false),
|
|
||||||
hasContext: vi.fn().mockResolvedValue(false),
|
|
||||||
clearScope: vi.fn().mockResolvedValue(0),
|
|
||||||
};
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [
|
|
||||||
NavigationStateService,
|
|
||||||
{ provide: Location, useValue: locationMock },
|
|
||||||
{ provide: Router, useValue: routerMock },
|
|
||||||
{ provide: NavigationContextService, useValue: contextServiceMock },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
service = TestBed.inject(NavigationStateService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be created', () => {
|
|
||||||
expect(service).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Context Preservation Methods Tests
|
|
||||||
|
|
||||||
describe('preserveContext', () => {
|
|
||||||
it('should call contextService.setContext with correct parameters', async () => {
|
|
||||||
const data: ReturnUrlContext = { returnUrl: '/test-page' };
|
|
||||||
const scopeKey = 'process-123';
|
|
||||||
|
|
||||||
await service.preserveContext(data, scopeKey);
|
|
||||||
|
|
||||||
expect(contextServiceMock.setContext).toHaveBeenCalledWith(data, scopeKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should work without scope key', async () => {
|
|
||||||
const data: ReturnUrlContext = { returnUrl: '/test-page' };
|
|
||||||
|
|
||||||
await service.preserveContext(data);
|
|
||||||
|
|
||||||
expect(contextServiceMock.setContext).toHaveBeenCalledWith(data, undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('restoreContext', () => {
|
|
||||||
it('should call contextService.getContext with correct parameters', async () => {
|
|
||||||
const expectedData: ReturnUrlContext = { returnUrl: '/test-page' };
|
|
||||||
contextServiceMock.getContext.mockResolvedValue(expectedData);
|
|
||||||
|
|
||||||
const result = await service.restoreContext<ReturnUrlContext>('scope-123');
|
|
||||||
|
|
||||||
expect(contextServiceMock.getContext).toHaveBeenCalledWith('scope-123');
|
|
||||||
expect(result).toEqual(expectedData);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when context not found', async () => {
|
|
||||||
contextServiceMock.getContext.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await service.restoreContext();
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should work without parameters', async () => {
|
|
||||||
const expectedData: ReturnUrlContext = { returnUrl: '/test-page' };
|
|
||||||
contextServiceMock.getContext.mockResolvedValue(expectedData);
|
|
||||||
|
|
||||||
const result = await service.restoreContext<ReturnUrlContext>();
|
|
||||||
|
|
||||||
expect(contextServiceMock.getContext).toHaveBeenCalledWith(undefined);
|
|
||||||
expect(result).toEqual(expectedData);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('restoreAndClearContext', () => {
|
|
||||||
it('should call contextService.getAndClearContext with correct parameters', async () => {
|
|
||||||
const expectedData: ReturnUrlContext = { returnUrl: '/test-page' };
|
|
||||||
contextServiceMock.getAndClearContext.mockResolvedValue(expectedData);
|
|
||||||
|
|
||||||
const result = await service.restoreAndClearContext<ReturnUrlContext>('scope-123');
|
|
||||||
|
|
||||||
expect(contextServiceMock.getAndClearContext).toHaveBeenCalledWith('scope-123');
|
|
||||||
expect(result).toEqual(expectedData);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when context not found', async () => {
|
|
||||||
contextServiceMock.getAndClearContext.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await service.restoreAndClearContext();
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('clearPreservedContext', () => {
|
|
||||||
it('should call contextService.clearContext and return result', async () => {
|
|
||||||
contextServiceMock.clearContext.mockResolvedValue(true);
|
|
||||||
|
|
||||||
const result = await service.clearPreservedContext('scope-123');
|
|
||||||
|
|
||||||
expect(contextServiceMock.clearContext).toHaveBeenCalledWith('scope-123');
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when context not found', async () => {
|
|
||||||
contextServiceMock.clearContext.mockResolvedValue(false);
|
|
||||||
|
|
||||||
const result = await service.clearPreservedContext();
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hasPreservedContext', () => {
|
|
||||||
it('should call contextService.hasContext and return result', async () => {
|
|
||||||
contextServiceMock.hasContext.mockResolvedValue(true);
|
|
||||||
|
|
||||||
const result = await service.hasPreservedContext('scope-123');
|
|
||||||
|
|
||||||
expect(contextServiceMock.hasContext).toHaveBeenCalledWith('scope-123');
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when context not found', async () => {
|
|
||||||
contextServiceMock.hasContext.mockResolvedValue(false);
|
|
||||||
|
|
||||||
const result = await service.hasPreservedContext();
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('navigateWithPreservedContext', () => {
|
|
||||||
it('should preserve context and navigate', async () => {
|
|
||||||
const data: ReturnUrlContext = { returnUrl: '/reward/cart', customerId: 123 };
|
|
||||||
const commands = ['/customer/search'];
|
|
||||||
const scopeKey = 'process-123';
|
|
||||||
|
|
||||||
routerMock.navigate.mockResolvedValue(true);
|
|
||||||
|
|
||||||
const result = await service.navigateWithPreservedContext(commands, data, scopeKey);
|
|
||||||
|
|
||||||
expect(contextServiceMock.setContext).toHaveBeenCalledWith(data, scopeKey);
|
|
||||||
expect(routerMock.navigate).toHaveBeenCalledWith(commands, {
|
|
||||||
state: data,
|
|
||||||
});
|
|
||||||
expect(result).toEqual({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should merge navigation extras', async () => {
|
|
||||||
const data: ReturnUrlContext = { returnUrl: '/test' };
|
|
||||||
const commands = ['/page'];
|
|
||||||
const extras = { queryParams: { foo: 'bar' } };
|
|
||||||
|
|
||||||
routerMock.navigate.mockResolvedValue(true);
|
|
||||||
|
|
||||||
await service.navigateWithPreservedContext(commands, data, undefined, extras);
|
|
||||||
|
|
||||||
expect(routerMock.navigate).toHaveBeenCalledWith(commands, {
|
|
||||||
queryParams: { foo: 'bar' },
|
|
||||||
state: data,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when navigation fails', async () => {
|
|
||||||
const data: ReturnUrlContext = { returnUrl: '/test' };
|
|
||||||
const commands = ['/page'];
|
|
||||||
|
|
||||||
routerMock.navigate.mockResolvedValue(false);
|
|
||||||
|
|
||||||
const result = await service.navigateWithPreservedContext(commands, data);
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('clearScopeContexts', () => {
|
|
||||||
it('should call contextService.clearScope and return count', async () => {
|
|
||||||
contextServiceMock.clearScope.mockResolvedValue(3);
|
|
||||||
|
|
||||||
const result = await service.clearScopeContexts();
|
|
||||||
|
|
||||||
expect(contextServiceMock.clearScope).toHaveBeenCalled();
|
|
||||||
expect(result).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 when no contexts cleared', async () => {
|
|
||||||
contextServiceMock.clearScope.mockResolvedValue(0);
|
|
||||||
|
|
||||||
const result = await service.clearScopeContexts();
|
|
||||||
|
|
||||||
expect(result).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { Router, NavigationExtras } from '@angular/router';
|
|
||||||
import { NavigationContextService } from './navigation-context.service';
|
|
||||||
import { NavigationContextData } from './navigation-context.types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for managing navigation context preservation across multi-step flows.
|
|
||||||
*
|
|
||||||
* This service provides automatic context preservation using tab metadata,
|
|
||||||
* allowing navigation state to survive intermediate navigations. Contexts are
|
|
||||||
* automatically scoped to the active tab and cleaned up when the tab closes.
|
|
||||||
*
|
|
||||||
* ## Context Preservation for Multi-Step Flows
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Start of flow - preserve context (automatically scoped to active tab)
|
|
||||||
* await navigationStateService.preserveContext({
|
|
||||||
* returnUrl: '/reward/cart',
|
|
||||||
* customerId: 123
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // ... multiple intermediate navigations happen ...
|
|
||||||
* await router.navigate(['/customer/details']);
|
|
||||||
* await router.navigate(['/add-shipping-address']);
|
|
||||||
*
|
|
||||||
* // End of flow - restore and cleanup (auto-scoped to active tab)
|
|
||||||
* const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
|
|
||||||
* if (context?.returnUrl) {
|
|
||||||
* await router.navigateByUrl(context.returnUrl);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* ## Simplified Navigation with Context
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Navigate and preserve context in one call
|
|
||||||
* const { success } = await navigationStateService.navigateWithPreservedContext(
|
|
||||||
* ['/customer/search'],
|
|
||||||
* { returnUrl: '/reward/cart' }
|
|
||||||
* );
|
|
||||||
*
|
|
||||||
* // Later, restore and navigate back
|
|
||||||
* const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
|
|
||||||
* if (context?.returnUrl) {
|
|
||||||
* await router.navigateByUrl(context.returnUrl);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* ## Automatic Tab Cleanup
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* ngOnDestroy() {
|
|
||||||
* // Clean up all contexts when tab closes (auto-uses active tab ID)
|
|
||||||
* this.navigationStateService.clearScopeContexts();
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Key Features:
|
|
||||||
* - ✅ Automatic tab scoping using TabService
|
|
||||||
* - ✅ Stored in tab metadata (automatic cleanup when tab closes)
|
|
||||||
* - ✅ Type-safe with TypeScript generics and Zod validation
|
|
||||||
* - ✅ Automatic cleanup with restoreAndClearContext()
|
|
||||||
* - ✅ Support for multiple custom scopes per tab
|
|
||||||
* - ✅ No manual expiration management needed
|
|
||||||
* - ✅ Platform-agnostic (works with SSR)
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class NavigationStateService {
|
|
||||||
readonly #router = inject(Router);
|
|
||||||
readonly #contextService = inject(NavigationContextService);
|
|
||||||
|
|
||||||
// Context Preservation Methods
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preserve navigation state for multi-step flows.
|
|
||||||
*
|
|
||||||
* This method stores navigation context in tab metadata, allowing it to
|
|
||||||
* persist across intermediate navigations within a flow. Contexts are automatically
|
|
||||||
* scoped to the active tab, with optional custom scope for different flows.
|
|
||||||
*
|
|
||||||
* Use this when starting a flow that will have intermediate navigations
|
|
||||||
* before returning to the original location.
|
|
||||||
*
|
|
||||||
* @template T The type of state data being preserved
|
|
||||||
* @param state The navigation state to preserve
|
|
||||||
* @param customScope Optional custom scope within the tab (e.g., 'customer-details')
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Preserve context for default tab scope
|
|
||||||
* await navigationStateService.preserveContext({ returnUrl: '/products' });
|
|
||||||
*
|
|
||||||
* // Preserve context for custom scope within tab
|
|
||||||
* await navigationStateService.preserveContext({ customerId: 42 }, 'customer-details');
|
|
||||||
*
|
|
||||||
* // ... multiple intermediate navigations ...
|
|
||||||
*
|
|
||||||
* // Restore at end of flow
|
|
||||||
* const context = await navigationStateService.restoreContext<{ returnUrl: string }>();
|
|
||||||
* if (context?.returnUrl) {
|
|
||||||
* await router.navigateByUrl(context.returnUrl);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async preserveContext<T extends NavigationContextData>(
|
|
||||||
state: T,
|
|
||||||
customScope?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.#contextService.setContext(state, customScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patch preserved navigation state.
|
|
||||||
*
|
|
||||||
* Merges partial state with existing preserved context, keeping unspecified properties intact.
|
|
||||||
* This is useful when you need to update or clear specific properties without replacing
|
|
||||||
* the entire context. Properties set to `undefined` will be removed.
|
|
||||||
*
|
|
||||||
* Use cases:
|
|
||||||
* - Clear a trigger flag while preserving return URL
|
|
||||||
* - Update one property in a multi-property context
|
|
||||||
* - Remove specific properties from context
|
|
||||||
*
|
|
||||||
* @template T The type of state data being patched
|
|
||||||
* @param partialState The partial state to merge (properties set to undefined will be removed)
|
|
||||||
* @param customScope Optional custom scope within the tab
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Clear the autoTriggerContinueFn flag while preserving returnUrl
|
|
||||||
* await navigationStateService.patchContext(
|
|
||||||
* { autoTriggerContinueFn: undefined },
|
|
||||||
* 'select-customer'
|
|
||||||
* );
|
|
||||||
*
|
|
||||||
* // Update selectedTab while keeping other properties
|
|
||||||
* await navigationStateService.patchContext(
|
|
||||||
* { selectedTab: 'rewards' },
|
|
||||||
* 'customer-flow'
|
|
||||||
* );
|
|
||||||
*
|
|
||||||
* // Add a new property to existing context
|
|
||||||
* await navigationStateService.patchContext(
|
|
||||||
* { shippingAddressId: 123 },
|
|
||||||
* 'checkout-flow'
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async patchContext<T extends NavigationContextData>(
|
|
||||||
partialState: Partial<T>,
|
|
||||||
customScope?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.#contextService.patchContext(partialState, customScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore preserved navigation state.
|
|
||||||
*
|
|
||||||
* Retrieves a previously preserved navigation context for the active tab scope,
|
|
||||||
* or a custom scope if specified.
|
|
||||||
*
|
|
||||||
* This method does NOT remove the context - use clearPreservedContext() or
|
|
||||||
* restoreAndClearContext() for automatic cleanup.
|
|
||||||
*
|
|
||||||
* @template T The expected type of the preserved state
|
|
||||||
* @param customScope Optional custom scope (defaults to active tab scope)
|
|
||||||
* @returns The preserved state, or null if not found
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Restore from default tab scope
|
|
||||||
* const context = navigationStateService.restoreContext<{ returnUrl: string }>();
|
|
||||||
* if (context?.returnUrl) {
|
|
||||||
* console.log('Returning to:', context.returnUrl);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // Restore from custom scope
|
|
||||||
* const context = navigationStateService.restoreContext<{ customerId: number }>('customer-details');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async restoreContext<T extends NavigationContextData = NavigationContextData>(
|
|
||||||
customScope?: string,
|
|
||||||
): Promise<T | null> {
|
|
||||||
return await this.#contextService.getContext<T>(customScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore and automatically clear preserved navigation state.
|
|
||||||
*
|
|
||||||
* Retrieves a preserved navigation context and removes it from tab metadata in one operation.
|
|
||||||
* Use this when completing a flow to clean up automatically.
|
|
||||||
*
|
|
||||||
* @template T The expected type of the preserved state
|
|
||||||
* @param customScope Optional custom scope (defaults to active tab scope)
|
|
||||||
* @returns The preserved state, or null if not found
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Restore and clear from default tab scope
|
|
||||||
* const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
|
|
||||||
* if (context?.returnUrl) {
|
|
||||||
* await router.navigateByUrl(context.returnUrl);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // Restore and clear from custom scope
|
|
||||||
* const context = await navigationStateService.restoreAndClearContext<{ customerId: number }>('customer-details');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async restoreAndClearContext<
|
|
||||||
T extends NavigationContextData = NavigationContextData,
|
|
||||||
>(customScope?: string): Promise<T | null> {
|
|
||||||
return await this.#contextService.getAndClearContext<T>(customScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear a preserved navigation context.
|
|
||||||
*
|
|
||||||
* Removes a context from tab metadata without returning its data.
|
|
||||||
* Use this for explicit cleanup when you no longer need the preserved state.
|
|
||||||
*
|
|
||||||
* @param customScope Optional custom scope (defaults to active tab scope)
|
|
||||||
* @returns true if context was found and cleared, false otherwise
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Clear default tab scope context
|
|
||||||
* await navigationStateService.clearPreservedContext();
|
|
||||||
*
|
|
||||||
* // Clear custom scope context
|
|
||||||
* await navigationStateService.clearPreservedContext('customer-details');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async clearPreservedContext(customScope?: string): Promise<boolean> {
|
|
||||||
return await this.#contextService.clearContext(customScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a preserved context exists.
|
|
||||||
*
|
|
||||||
* @param customScope Optional custom scope (defaults to active tab scope)
|
|
||||||
* @returns true if context exists, false otherwise
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Check default tab scope
|
|
||||||
* if (navigationStateService.hasPreservedContext()) {
|
|
||||||
* const context = navigationStateService.restoreContext();
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // Check custom scope
|
|
||||||
* if (navigationStateService.hasPreservedContext('customer-details')) {
|
|
||||||
* const context = navigationStateService.restoreContext('customer-details');
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async hasPreservedContext(customScope?: string): Promise<boolean> {
|
|
||||||
return await this.#contextService.hasContext(customScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate while preserving context state.
|
|
||||||
*
|
|
||||||
* Convenience method that combines navigation with context preservation.
|
|
||||||
* The context will be stored in tab metadata and available throughout the
|
|
||||||
* navigation flow and any intermediate navigations. Context is automatically
|
|
||||||
* scoped to the active tab.
|
|
||||||
*
|
|
||||||
* @param commands Navigation commands (same as Router.navigate)
|
|
||||||
* @param state The state to preserve
|
|
||||||
* @param customScope Optional custom scope within the tab
|
|
||||||
* @param extras Optional navigation extras
|
|
||||||
* @returns Promise resolving to navigation success status
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Navigate and preserve context
|
|
||||||
* const { success } = await navigationStateService.navigateWithPreservedContext(
|
|
||||||
* ['/customer/search'],
|
|
||||||
* { returnUrl: '/reward/cart', customerId: 123 }
|
|
||||||
* );
|
|
||||||
*
|
|
||||||
* // Later, retrieve and navigate back
|
|
||||||
* const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
|
|
||||||
* if (context?.returnUrl) {
|
|
||||||
* await router.navigateByUrl(context.returnUrl);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async navigateWithPreservedContext<T extends NavigationContextData>(
|
|
||||||
commands: unknown[],
|
|
||||||
state: T,
|
|
||||||
customScope?: string,
|
|
||||||
extras?: NavigationExtras,
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
await this.preserveContext(state, customScope);
|
|
||||||
|
|
||||||
// Also pass state via router for immediate access
|
|
||||||
const navigationExtras: NavigationExtras = {
|
|
||||||
...extras,
|
|
||||||
state,
|
|
||||||
};
|
|
||||||
|
|
||||||
const success = await this.#router.navigate(commands, navigationExtras);
|
|
||||||
|
|
||||||
return { success };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all preserved contexts for the active tab.
|
|
||||||
*
|
|
||||||
* Removes all contexts for the active tab (both default and custom scopes).
|
|
||||||
* Useful for cleanup when a tab is closed.
|
|
||||||
*
|
|
||||||
* @returns The number of contexts cleared
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* // Clear all contexts for active tab
|
|
||||||
* ngOnDestroy() {
|
|
||||||
* const cleared = this.navigationStateService.clearScopeContexts();
|
|
||||||
* console.log(`Cleared ${cleared} contexts`);
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
async clearScopeContexts(): Promise<number> {
|
|
||||||
return await this.#contextService.clearScope();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
/**
|
|
||||||
* Type definition for navigation state that can be passed through Angular Router.
|
|
||||||
* Use generic type parameter to ensure type safety for your specific state shape.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* interface MyNavigationState extends NavigationState {
|
|
||||||
* returnUrl: string;
|
|
||||||
* customerId: number;
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export interface NavigationState {
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Common navigation state for "return URL" pattern.
|
|
||||||
* Used when navigating to a page and needing to return to the previous location.
|
|
||||||
*/
|
|
||||||
export interface ReturnUrlNavigationState extends NavigationState {
|
|
||||||
returnUrl: string;
|
|
||||||
}
|
|
||||||
@@ -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/core/navigation',
|
|
||||||
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-core-navigation.xml' }],
|
|
||||||
],
|
|
||||||
coverage: {
|
|
||||||
reportsDirectory: '../../../coverage/libs/core/navigation',
|
|
||||||
provider: 'v8' as const,
|
|
||||||
reporter: ['text', 'cobertura'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -71,7 +71,6 @@
|
|||||||
"@isa/core/auth": ["libs/core/auth/src/index.ts"],
|
"@isa/core/auth": ["libs/core/auth/src/index.ts"],
|
||||||
"@isa/core/config": ["libs/core/config/src/index.ts"],
|
"@isa/core/config": ["libs/core/config/src/index.ts"],
|
||||||
"@isa/core/logging": ["libs/core/logging/src/index.ts"],
|
"@isa/core/logging": ["libs/core/logging/src/index.ts"],
|
||||||
"@isa/core/navigation": ["libs/core/navigation/src/index.ts"],
|
|
||||||
"@isa/core/storage": ["libs/core/storage/src/index.ts"],
|
"@isa/core/storage": ["libs/core/storage/src/index.ts"],
|
||||||
"@isa/core/tabs": ["libs/core/tabs/src/index.ts"],
|
"@isa/core/tabs": ["libs/core/tabs/src/index.ts"],
|
||||||
"@isa/crm/data-access": ["libs/crm/data-access/src/index.ts"],
|
"@isa/crm/data-access": ["libs/crm/data-access/src/index.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user