Merged PR 1969: Reward Shopping Cart Implementation with Navigation State Management and Shipping Address Integration

1. Reward Shopping Cart Implementation
  - New shopping cart with quantity control and availability checking
  - Responsive shopping cart item component with improved CSS styling
  - Shipping address integration in cart
  - Customer reward card and billing/shipping address components

  2. Navigation State Management Library (@isa/core/navigation)
  - New library with type-safe navigation context service (373 lines)
  - Navigation state service (287 lines) for temporary state between routes
  - Comprehensive test coverage (668 + 227 lines of tests)
  - Documentation (792 lines in README.md)
  - Replaces query parameters for passing temporary navigation context

  3. CRM Shipping Address Services
  - New ShippingAddressService with fetching and validation
  - CustomerShippingAddressResource and CustomerShippingAddressesResource
  - Zod schemas for data validation

  4. Additional Improvements
  - Enhanced searchbox accessibility with ARIA support
  - Availability data access rework for better fetching/mapping
  - Storybook tooltip variant support
  - Vitest JUnit and Cobertura reporting configuration

Related work items: #5382, #5383, #5384
This commit is contained in:
Lorenz Hilpert
2025-10-15 14:59:34 +00:00
committed by Nino Righi
parent f15848d5c0
commit 596ae1da1b
45 changed files with 3793 additions and 344 deletions

View File

@@ -11,7 +11,7 @@ This is a sophisticated Angular 20.1.2 monorepo managed by Nx 21.3.2. The main a
### Monorepo Structure
- **apps/isa-app**: Main Angular application
- **libs/**: Reusable libraries organized by domain and type
- **core/**: Core utilities (config, logging, storage, tabs)
- **core/**: Core utilities (config, logging, storage, tabs, navigation)
- **common/**: Shared utilities (data-access, decorators, print)
- **ui/**: UI component libraries (buttons, dialogs, inputs, etc.)
- **shared/**: Shared domain components (filter, scanner, product components)
@@ -141,6 +141,7 @@ npx nx affected:test
- **Custom RxJS Operators**: Specialized operators like `takeUntilAborted()`, `takeUntilKeydown()`
- **Error Handling**: `tapResponse()` for handling success/error states in stores
- **Lifecycle Hooks**: `withHooks()` for cleanup and initialization (e.g., orphaned entity cleanup)
- **Navigation State**: Use `@isa/core/navigation` for temporary navigation context (return URLs, wizard state) instead of query parameters
## Styling and Design System
- **Tailwind CSS**: Primary styling framework with extensive ISA-specific customization
@@ -153,6 +154,21 @@ npx nx affected:test
- **Typography System**: 14 custom text utilities (`.isa-text-heading-1-bold`, `.isa-text-body-2-regular`)
- **UI Component Libraries**: 15 specialized UI libraries with consistent API patterns
- **Storybook Integration**: Component documentation and development environment
- **Responsive Design & Breakpoints**:
- **Breakpoint Service**: Use `@isa/ui/layout` for reactive breakpoint detection in components
- **Available Breakpoints**:
- `Breakpoint.Tablet`: `(max-width: 1279px)` - Mobile and tablet devices
- `Breakpoint.Desktop`: `(min-width: 1280px) and (max-width: 1439px)` - Standard desktop screens
- `Breakpoint.DekstopL`: `(min-width: 1440px) and (max-width: 1919px)` - Large desktop screens
- `Breakpoint.DekstopXL`: `(min-width: 1920px)` - Extra large desktop screens
- **Usage Pattern**:
```typescript
import { breakpoint, Breakpoint } from '@isa/ui/layout';
isDesktop = breakpoint([Breakpoint.Desktop, Breakpoint.DekstopL, Breakpoint.DekstopXL]);
```
- **Template Integration**: Use with `@if` control flow for conditional rendering based on screen size
- **Note**: Prefer the breakpoint service over CSS-only solutions (hidden/flex classes) for proper server-side rendering and better maintainability
## API Integration and Data Access
- **Generated Swagger Clients**: 10 auto-generated TypeScript clients from OpenAPI specs in `generated/swagger/`
@@ -279,6 +295,7 @@ npx nx affected:test
- **Git Workflow**: Default branch is `develop` (not main), use conventional commits without co-author tags
- **Design System**: Use ISA-specific Tailwind utilities (`isa-accent-*`, `isa-text-*`) and custom breakpoints (`isa-desktop-*`)
- **Logging**: Use centralized logging service (`@isa/core/logging`) with contextual information for debugging
- **Navigation State**: Use `@isa/core/navigation` for passing temporary state between routes (return URLs, form context) instead of query parameters - keeps URLs clean and state reliable
## Claude Code Workflow Definition

View File

@@ -1,213 +1,224 @@
<shared-loader [loading]="fetching$ | async" background="true" spinnerSize="32">
<div class="overflow-scroll max-h-[calc(100vh-15rem)]">
<div class="customer-details-header grid grid-flow-row pb-6">
<div
class="customer-details-header-actions flex flex-row justify-end pt-4 px-4"
>
<page-customer-menu
[customerId]="customerId$ | async"
[processId]="processId$ | async"
[hasCustomerCard]="hasKundenkarte$ | async"
[showCustomerDetails]="false"
></page-customer-menu>
</div>
<div class="customer-details-header-body text-center -mt-3">
<h1 class="text-[1.625rem] font-bold">
{{ (isBusinessKonto$ | async) ? 'Firmendetails' : 'Kundendetails' }}
</h1>
<p>Sind Ihre Kundendaten korrekt?</p>
</div>
</div>
<div
class="customer-details-customer-type flex flex-row justify-between items-center bg-surface-2 text-surface-2-content h-14"
>
<div
class="pl-4 font-bold grid grid-flow-col justify-start items-center gap-2"
>
<shared-icon [icon]="customerType$ | async"></shared-icon>
<span>
{{ customerType$ | async }}
</span>
</div>
@if (showEditButton$ | async) {
@if (editRoute$ | async; as editRoute) {
<a
[routerLink]="editRoute.path"
[queryParams]="editRoute.queryParams"
[queryParamsHandling]="'merge'"
class="btn btn-label font-bold text-brand"
>
Bearbeiten
</a>
}
}
</div>
<div
class="customer-details-customer-main-data px-5 py-3 grid grid-flow-row gap-3"
>
<div class="flex flex-row">
<div class="data-label">Erstellungsdatum</div>
@if (created$ | async; as created) {
<div class="data-value">
{{ created | date: 'dd.MM.yyyy' }} |
{{ created | date: 'HH:mm' }} Uhr
</div>
}
</div>
<div class="flex flex-row">
<div class="data-label">Kundennummer</div>
<div class="data-value">{{ customerNumber$ | async }}</div>
</div>
@if (customerNumberDig$ | async; as customerNumberDig) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-DIG</div>
<div class="data-value">{{ customerNumberDig }}</div>
</div>
}
@if (customerNumberBeeline$ | async; as customerNumberBeeline) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-BEELINE</div>
<div class="data-value">{{ customerNumberBeeline }}</div>
</div>
}
</div>
@if (isBusinessKonto$ | async) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
<div class="customer-details-customer-main-row">
<div class="data-label">Anrede</div>
<div class="data-value">{{ gender$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Titel</div>
<div class="data-value">{{ title$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Nachname</div>
<div class="data-value">{{ lastName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Vorname</div>
<div class="data-value">{{ firstName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">E-Mail</div>
<div class="data-value">{{ email$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Straße</div>
<div class="data-value">{{ street$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Hausnr.</div>
<div class="data-value">{{ streetNumber$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">PLZ</div>
<div class="data-value">{{ zipCode$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Ort</div>
<div class="data-value">{{ city$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Adresszusatz</div>
<div class="data-value">{{ info$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Land</div>
@if (country$ | async; as country) {
<div class="data-value">{{ country | country }}</div>
}
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Festnetznr.</div>
<div class="data-value">{{ landline$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Mobilnr.</div>
<div class="data-value">{{ mobile$ | async }}</div>
</div>
@if (!(isBusinessKonto$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Geburtstag</div>
<div class="data-value">
{{ dateOfBirth$ | async | date: 'dd.MM.yyyy' }}
</div>
</div>
}
@if (!(isBusinessKonto$ | async) && (organisationName$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
@if (!(isOnlineOrCustomerCardUser$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
}
<page-details-main-view-billing-addresses></page-details-main-view-billing-addresses>
<page-details-main-view-delivery-addresses></page-details-main-view-delivery-addresses>
<div class="h-24"></div>
</div>
</shared-loader>
@if (!isRewardTab()) {
@if (shoppingCartHasNoItems$ | async) {
<button
type="button"
(click)="continue()"
class="text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
[disabled]="showLoader$ | async"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Weiter zur Artikelsuche</shared-loader
>
</button>
}
@if (shoppingCartHasItems$ | async) {
<button
type="button"
(click)="continue()"
class="text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
[disabled]="showLoader$ | async"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Weiter zum Warenkorb</shared-loader
>
</button>
}
} @else {
<button
type="button"
(click)="continueReward()"
class="w-60 text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
[disabled]="!(hasKundenkarte$ | async)"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Auswählen</shared-loader
>
</button>
}
<shared-loader [loading]="fetching$ | async" background="true" spinnerSize="32">
<div class="overflow-scroll max-h-[calc(100vh-15rem)]">
<div class="customer-details-header grid grid-flow-row pb-6">
<div
class="customer-details-header-actions flex flex-row justify-end pt-4 px-4"
>
<page-customer-menu
[customerId]="customerId$ | async"
[processId]="processId$ | async"
[hasCustomerCard]="hasKundenkarte$ | async"
[showCustomerDetails]="false"
></page-customer-menu>
</div>
<div class="customer-details-header-body text-center -mt-3">
<h1 class="text-[1.625rem] font-bold">
{{ (isBusinessKonto$ | async) ? 'Firmendetails' : 'Kundendetails' }}
</h1>
<p>Sind Ihre Kundendaten korrekt?</p>
</div>
</div>
<div
class="customer-details-customer-type flex flex-row justify-between items-center bg-surface-2 text-surface-2-content h-14"
>
<div
class="pl-4 font-bold grid grid-flow-col justify-start items-center gap-2"
>
<shared-icon [icon]="customerType$ | async"></shared-icon>
<span>
{{ customerType$ | async }}
</span>
</div>
@if (showEditButton$ | async) {
@if (editRoute$ | async; as editRoute) {
<a
[routerLink]="editRoute.path"
[queryParams]="editRoute.queryParams"
[queryParamsHandling]="'merge'"
class="btn btn-label font-bold text-brand"
>
Bearbeiten
</a>
}
}
</div>
<div
class="customer-details-customer-main-data px-5 py-3 grid grid-flow-row gap-3"
>
<div class="flex flex-row">
<div class="data-label">Erstellungsdatum</div>
@if (created$ | async; as created) {
<div class="data-value">
{{ created | date: 'dd.MM.yyyy' }} |
{{ created | date: 'HH:mm' }} Uhr
</div>
}
</div>
<div class="flex flex-row">
<div class="data-label">Kundennummer</div>
<div class="data-value">{{ customerNumber$ | async }}</div>
</div>
@if (customerNumberDig$ | async; as customerNumberDig) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-DIG</div>
<div class="data-value">{{ customerNumberDig }}</div>
</div>
}
@if (customerNumberBeeline$ | async; as customerNumberBeeline) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-BEELINE</div>
<div class="data-value">{{ customerNumberBeeline }}</div>
</div>
}
</div>
@if (isBusinessKonto$ | async) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
<div class="customer-details-customer-main-row">
<div class="data-label">Anrede</div>
<div class="data-value">{{ gender$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Titel</div>
<div class="data-value">{{ title$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Nachname</div>
<div class="data-value">{{ lastName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Vorname</div>
<div class="data-value">{{ firstName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">E-Mail</div>
<div class="data-value">{{ email$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Straße</div>
<div class="data-value">{{ street$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Hausnr.</div>
<div class="data-value">{{ streetNumber$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">PLZ</div>
<div class="data-value">{{ zipCode$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Ort</div>
<div class="data-value">{{ city$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Adresszusatz</div>
<div class="data-value">{{ info$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Land</div>
@if (country$ | async; as country) {
<div class="data-value">{{ country | country }}</div>
}
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Festnetznr.</div>
<div class="data-value">{{ landline$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Mobilnr.</div>
<div class="data-value">{{ mobile$ | async }}</div>
</div>
@if (!(isBusinessKonto$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Geburtstag</div>
<div class="data-value">
{{ dateOfBirth$ | async | date: 'dd.MM.yyyy' }}
</div>
</div>
}
@if (!(isBusinessKonto$ | async) && (organisationName$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
@if (!(isOnlineOrCustomerCardUser$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
}
<page-details-main-view-billing-addresses></page-details-main-view-billing-addresses>
<page-details-main-view-delivery-addresses></page-details-main-view-delivery-addresses>
<div class="h-24"></div>
</div>
</shared-loader>
@if (hasReturnUrl()) {
<button
type="button"
(click)="continueReward()"
class="w-60 text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
[disabled]="!(hasKundenkarte$ | async)"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Auswählen</shared-loader
>
</button>
} @else if (!isRewardTab()) {
@if (shoppingCartHasNoItems$ | async) {
<button
type="button"
(click)="continue()"
class="text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
[disabled]="showLoader$ | async"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Weiter zur Artikelsuche</shared-loader
>
</button>
}
@if (shoppingCartHasItems$ | async) {
<button
type="button"
(click)="continue()"
class="text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
[disabled]="showLoader$ | async"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Weiter zum Warenkorb</shared-loader
>
</button>
}
} @else {
<button
type="button"
(click)="continueReward()"
class="w-60 text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
[disabled]="!(hasKundenkarte$ | async)"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
>Auswählen</shared-loader
>
</button>
}

View File

@@ -5,6 +5,7 @@ import {
OnDestroy,
inject,
linkedSignal,
signal,
} from '@angular/core';
import { Subject, combineLatest } from 'rxjs';
import { first, map, switchMap, takeUntil } from 'rxjs/operators';
@@ -39,6 +40,7 @@ import { injectTab } from '@isa/core/tabs';
import { toSignal } from '@angular/core/rxjs-interop';
import { CrmTabMetadataService, Customer } from '@isa/crm/data-access';
import { CustomerAdapter } from '@isa/checkout/data-access';
import { NavigationStateService } from '@isa/core/navigation';
export interface CustomerDetailsViewMainState {
isBusy: boolean;
@@ -68,12 +70,16 @@ export class CustomerDetailsViewMainComponent
private _router = inject(Router);
private _activatedRoute = inject(ActivatedRoute);
private _genderSettings = inject(GenderSettingsService);
private _navigationState = inject(NavigationStateService);
private _onDestroy$ = new Subject<void>();
customerService = inject(CrmCustomerService);
crmTabMetadataService = inject(CrmTabMetadataService);
tab = injectTab();
// Signal to track if return URL exists
hasReturnUrlSignal = signal(false);
fetching$ = combineLatest([
this._store.fetchingCustomer$,
this._store.fetchingCustomerList$,
@@ -81,6 +87,24 @@ export class CustomerDetailsViewMainComponent
map(([fetchingCustomer, fetchingList]) => fetchingCustomer || fetchingList),
);
async getReturnUrlFromContext(): Promise<string | null> {
// Get from preserved context (survives intermediate navigations, auto-scoped to tab)
const context = await this._navigationState.restoreContext<{
returnUrl?: string;
}>('select-customer');
return context?.returnUrl ?? null;
}
async checkHasReturnUrl(): Promise<void> {
const hasContext = await this._navigationState.hasPreservedContext('select-customer');
this.hasReturnUrlSignal.set(hasContext);
}
hasReturnUrl(): boolean {
return this.hasReturnUrlSignal();
}
isBusy$ = this.select((s) => s.isBusy);
showLoader$ = combineLatest([this.fetching$, this.isBusy$]).pipe(
@@ -294,6 +318,9 @@ export class CustomerDetailsViewMainComponent
});
ngOnInit() {
// Check if we have a return URL context
this.checkHasReturnUrl();
this.processId$
.pipe(
takeUntil(this._onDestroy$),
@@ -339,6 +366,18 @@ export class CustomerDetailsViewMainComponent
this.processId,
this.customer.id,
);
// Restore from preserved context (auto-scoped to current tab) and clean up
const context = await this._navigationState.restoreAndClearContext<{
returnUrl?: string;
}>('select-customer');
if (context?.returnUrl) {
await this._router.navigateByUrl(context.returnUrl);
return;
}
// No returnUrl found, navigate to default reward page
await this._router.navigate(['/', this.processId, 'reward']);
}
}
@@ -402,15 +441,7 @@ export class CustomerDetailsViewMainComponent
this._router.navigate(path);
}
try {
} catch (error) {
this._modalService.error(
'Warenkorb kann dem Kunden nicht zugewiesen werden',
error,
);
} finally {
this.setIsBusy(false);
}
this.setIsBusy(false);
}
@logAsync
@@ -475,7 +506,7 @@ export class CustomerDetailsViewMainComponent
}
return true;
} catch (error) {
} catch {
this._modalService.open({
content: MessageModalComponent,
title: 'Warenkorb kann dem Kunden nicht zugewiesen werden',
@@ -563,6 +594,10 @@ export class CustomerDetailsViewMainComponent
processId: this.processId,
customerDto: this.customer,
});
this.crmTabMetadataService.setSelectedCustomerId(
this.processId,
this.customer.id,
);
}
@log

View File

@@ -1,73 +1,84 @@
<div class="searchbox-input-wrapper">
<div class="searchbox-hint-wrapper">
<input
id="searchbox"
class="searchbox-input"
autocomplete="off"
#input
type="text"
[placeholder]="placeholder"
[(ngModel)]="query"
(ngModelChange)="setQuery($event, true, true)"
(focus)="clearHint(); focused.emit(true)"
(blur)="focused.emit(false)"
(keyup)="onKeyup($event)"
(keyup.enter)="
tracker.trackEvent({ action: 'keyup enter', name: 'search' })
"
matomoTracker
#tracker="matomo"
matomoCategory="searchbox"
/>
@if (showHint) {
<div class="searchbox-hint" (click)="focus()">
{{ hint }}
</div>
}
</div>
@if (input.value) {
<button
(click)="clear(); focus()"
tabindex="-1"
class="searchbox-clear-btn"
type="button"
>
<shared-icon icon="close" [size]="32"></shared-icon>
</button>
}
@if (!loading) {
@if (!showScannerButton) {
<button
tabindex="0"
class="searchbox-search-btn"
type="button"
(click)="emitSearch()"
[disabled]="completeValue !== query"
matomoClickAction="click"
matomoClickCategory="searchbox"
matomoClickName="search"
>
<ui-icon icon="search" size="1.5rem"></ui-icon>
</button>
}
@if (showScannerButton) {
<button
tabindex="0"
class="searchbox-scan-btn"
type="button"
(click)="startScan()"
matomoClickAction="open"
matomoClickCategory="searchbox"
matomoClickName="scanner"
>
<shared-icon icon="barcode-scan" [size]="32"></shared-icon>
</button>
}
}
@if (loading) {
<div class="searchbox-load-indicator">
<ui-icon icon="spinner" size="32px"></ui-icon>
</div>
}
</div>
<ng-content select="ui-autocomplete"></ng-content>
<div class="searchbox-input-wrapper" role="search">
<div class="searchbox-hint-wrapper">
<input
id="searchbox"
class="searchbox-input"
autocomplete="off"
#input
type="text"
[placeholder]="placeholder"
[(ngModel)]="query"
(ngModelChange)="setQuery($event, true, true)"
(focus)="clearHint(); focused.emit(true)"
(blur)="focused.emit(false)"
(keyup)="onKeyup($event)"
(keyup.enter)="
tracker.trackEvent({ action: 'keyup enter', name: 'search' })
"
matomoTracker
#tracker="matomo"
matomoCategory="searchbox"
aria-label="Search input"
aria-autocomplete="list"
[attr.aria-expanded]="autocomplete?.opend || null"
[attr.aria-controls]="autocomplete?.opend ? 'searchbox-autocomplete' : null"
[attr.aria-activedescendant]="autocomplete?.activeItem ? 'searchbox-item-' + autocomplete?.listKeyManager?.activeItemIndex : null"
[attr.aria-busy]="loading || null"
[attr.aria-describedby]="showHint ? 'searchbox-hint' : null"
/>
@if (showHint) {
<div id="searchbox-hint" class="searchbox-hint" (click)="focus()" aria-hidden="true">
{{ hint }}
</div>
}
</div>
@if (input.value) {
<button
(click)="clear(); focus()"
tabindex="-1"
class="searchbox-clear-btn"
type="button"
aria-label="Clear"
>
<shared-icon icon="close" [size]="32" aria-hidden="true"></shared-icon>
</button>
}
@if (!loading) {
@if (!showScannerButton) {
<button
tabindex="0"
class="searchbox-search-btn"
type="button"
(click)="emitSearch()"
[disabled]="completeValue !== query"
[attr.aria-disabled]="completeValue !== query || null"
matomoClickAction="click"
matomoClickCategory="searchbox"
matomoClickName="search"
aria-label="Search"
>
<ui-icon icon="search" size="1.5rem" aria-hidden="true"></ui-icon>
</button>
}
@if (showScannerButton) {
<button
tabindex="0"
class="searchbox-scan-btn"
type="button"
(click)="startScan()"
matomoClickAction="open"
matomoClickCategory="searchbox"
matomoClickName="scanner"
aria-label="Scan barcode"
>
<shared-icon icon="barcode-scan" [size]="32" aria-hidden="true"></shared-icon>
</button>
}
}
@if (loading) {
<div class="searchbox-load-indicator" role="status" aria-live="polite" aria-label="Loading search results">
<ui-icon icon="spinner" size="32px" aria-hidden="true"></ui-icon>
</div>
}
</div>
<ng-content select="ui-autocomplete"></ng-content>

View File

@@ -11,3 +11,346 @@
- Use for complex application state
- Follow feature-based store organization
- Implement proper error handling
## Navigation State
Navigation state refers to temporary data preserved between routes during navigation flows. Unlike global state or local component state, navigation state is transient and tied to a specific navigation flow within a tab.
### Storage Architecture
Navigation contexts are stored in **tab metadata** using `@isa/core/navigation`:
```typescript
// Context stored in tab metadata:
tab.metadata['navigation-contexts'] = {
'default': {
data: { returnUrl: '/cart', customerId: 123 },
createdAt: 1234567890
},
'customer-flow': {
data: { step: 2, selectedOptions: ['A', 'B'] },
createdAt: 1234567895
}
}
```
**Benefits:**
-**Automatic cleanup** when tabs close (no manual cleanup needed)
-**Tab isolation** (contexts don't leak between tabs)
-**Persistence** across page refresh (via TabService UserStorage)
-**Integration** with tab lifecycle management
### When to Use Navigation State
Use `@isa/core/navigation` for:
- **Return URLs**: Storing the previous route to navigate back to
- **Wizard/Multi-step Forms**: Passing context between wizard steps
- **Context Preservation**: Maintaining search queries, filters, or selections when drilling into details
- **Temporary Data**: Any data needed only for the current navigation flow within a tab
### When NOT to Use Navigation State
Avoid using navigation state for:
- **Persistent Data**: Use NgRx stores or services instead
- **Shareable URLs**: Use route parameters or query parameters if the URL should be bookmarkable
- **Long-lived State**: Use session storage or NgRx with persistence
- **Cross-tab Communication**: Use services with proper state management
### Best Practices
#### ✅ Do Use Navigation Context (Tab Metadata)
```typescript
// Good: Clean URLs, automatic cleanup, tab-scoped
navState.preserveContext({
returnUrl: '/customer-list',
searchQuery: 'John Doe',
context: 'reward-selection'
});
await router.navigate(['/customer', customerId]);
// Later (after intermediate navigations):
const context = navState.restoreAndClearContext<{ returnUrl: string }>();
await router.navigateByUrl(context.returnUrl);
```
#### ❌ Don't Use Query Parameters for Temporary State
```typescript
// Bad: URL pollution, can be overwritten, visible in browser bar
await router.navigate(['/customer', customerId], {
queryParams: {
returnUrl: '/customer-list',
searchQuery: 'John Doe'
}
});
// URL becomes: /customer/123?returnUrl=%2Fcustomer-list&searchQuery=John%20Doe
```
#### ❌ Don't Use Router State for Multi-Step Flows
```typescript
// Bad: Lost after intermediate navigations
await router.navigate(['/customer/search'], {
state: { returnUrl: '/reward/cart' } // Lost after next navigation!
});
```
### Integration with TabService
Navigation context relies on `TabService` for automatic tab scoping:
```typescript
// Context automatically scoped to active tab
const tabId = tabService.activatedTabId(); // e.g., 123
// When you preserve context:
navState.preserveContext({ returnUrl: '/cart' });
// Stored in: tab[123].metadata['navigation-contexts']['default']
// When you preserve with custom scope:
navState.preserveContext({ step: 2 }, 'wizard-flow');
// Stored in: tab[123].metadata['navigation-contexts']['wizard-flow']
```
**Automatic Cleanup:**
When the tab closes, all contexts stored in that tab's metadata are automatically removed. No manual cleanup required!
### Usage Example: Multi-Step Flow
**Start of Flow (preserving context):**
```typescript
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { NavigationStateService } from '@isa/core/navigation';
export class CustomerListComponent {
private router = inject(Router);
private navState = inject(NavigationStateService);
async viewCustomerDetails(customerId: number) {
// Preserve context before navigating
this.navState.preserveContext({
returnUrl: this.router.url,
searchQuery: this.searchForm.value.query
});
await this.router.navigate(['/customer', customerId]);
}
}
```
**Intermediate Navigation (context persists):**
```typescript
export class CustomerDetailsComponent {
private router = inject(Router);
async editAddress(addressId: number) {
// Context still preserved through intermediate navigations
await this.router.navigate(['/address/edit', addressId]);
}
}
```
**End of Flow (restoring context):**
```typescript
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { NavigationStateService } from '@isa/core/navigation';
interface CustomerNavigationContext {
returnUrl: string;
searchQuery?: string;
}
export class AddressEditComponent {
private router = inject(Router);
private navState = inject(NavigationStateService);
async complete() {
// Restore and clear context
const context = this.navState.restoreAndClearContext<CustomerNavigationContext>();
if (context?.returnUrl) {
await this.router.navigateByUrl(context.returnUrl);
} else {
// Fallback navigation
await this.router.navigate(['/customers']);
}
}
// For template usage
hasReturnUrl(): boolean {
return this.navState.hasPreservedContext();
}
}
```
**Template:**
```html
@if (hasReturnUrl()) {
<button (click)="complete()">Zurück</button>
}
```
### Advanced: Multiple Concurrent Flows
Use custom scopes to manage multiple flows in the same tab:
```typescript
export class DashboardComponent {
private navState = inject(NavigationStateService);
private router = inject(Router);
async startCustomerFlow() {
// Save context for customer flow
this.navState.preserveContext(
{ returnUrl: '/dashboard', flowType: 'customer' },
'customer-flow'
);
await this.router.navigate(['/customer/search']);
}
async startProductFlow() {
// Save context for product flow (different scope)
this.navState.preserveContext(
{ returnUrl: '/dashboard', flowType: 'product' },
'product-flow'
);
await this.router.navigate(['/product/search']);
}
async completeCustomerFlow() {
// Restore customer flow context
const context = this.navState.restoreAndClearContext('customer-flow');
if (context?.returnUrl) {
await this.router.navigateByUrl(context.returnUrl);
}
}
async completeProductFlow() {
// Restore product flow context
const context = this.navState.restoreAndClearContext('product-flow');
if (context?.returnUrl) {
await this.router.navigateByUrl(context.returnUrl);
}
}
}
```
### Architecture Decision
**Why Tab Metadata instead of SessionStorage?**
Tab metadata provides several advantages over SessionStorage:
- **Automatic Cleanup**: Contexts are automatically removed when tabs close (no manual cleanup or TTL management needed)
- **Better Integration**: Seamlessly integrated with tab lifecycle and TabService
- **Tab Isolation**: Impossible to leak contexts between tabs (scoped by tab ID)
- **Simpler Mental Model**: Contexts are "owned" by tabs, not stored globally
- **Persistence**: Tab metadata persists across page refresh via UserStorage
**Why not Query Parameters?**
Query parameters were traditionally used for passing state, but they have significant drawbacks:
- **URL Pollution**: Makes URLs long, ugly, and non-bookmarkable
- **Overwritable**: Intermediate navigations can overwrite query parameters
- **Security**: Sensitive data visible in browser URL bar
- **User Experience**: Affects URL sharing and bookmarking
**Why not Router State?**
Angular's Router state mechanism has limitations:
- **Lost After Navigation**: State is lost after the immediate navigation (doesn't survive intermediate navigations)
- **Not Persistent**: Doesn't survive page refresh
- **No Tab Scoping**: Can't isolate state by tab
**Why Tab Metadata is Better:**
Navigation context using tab metadata provides:
- **Survives Intermediate Navigations**: State persists across multiple navigation steps
- **Clean URLs**: No visible state in the URL bar
- **Reliable**: State survives page refresh (via TabService UserStorage)
- **Type-safe**: Full TypeScript support with generics
- **Platform-agnostic**: Works with SSR/Angular Universal
- **Automatic Cleanup**: No manual cleanup needed when tabs close
- **Tab Isolation**: Contexts automatically scoped to tabs
### Comparison with Other State Solutions
| Feature | Navigation Context | NgRx Store | Service State | Query Params | Router State |
|---------|-------------------|------------|---------------|--------------|--------------|
| **Scope** | Tab-scoped flow | Application-wide | Feature-specific | URL-based | Single navigation |
| **Persistence** | Until tab closes | Configurable | Component lifetime | URL lifetime | Lost after nav |
| **Survives Refresh** | ✅ Yes | ⚠️ Optional | ❌ No | ✅ Yes | ❌ No |
| **Survives Intermediate Navs** | ✅ Yes | ✅ Yes | ✅ Yes | ⚠️ Sometimes | ❌ No |
| **Automatic Cleanup** | ✅ Yes (tab close) | ❌ Manual | ❌ Manual | N/A | ✅ Yes |
| **Tab Isolation** | ✅ Yes | ❌ No | ❌ No | ❌ No | ❌ No |
| **Clean URLs** | ✅ Yes | N/A | N/A | ❌ No | ✅ Yes |
| **Shareability** | ❌ No | ❌ No | ❌ No | ✅ Yes | ❌ No |
| **Type Safety** | ✅ Yes | ✅ Yes | ✅ Yes | ⚠️ Limited | ✅ Yes |
| **Use Case** | Multi-step flow | Global state | Feature state | Bookmarkable state | Simple navigation |
### Best Practices for Navigation Context
#### ✅ Do
- **Use for temporary flow context** (return URLs, wizard state, search filters)
- **Use custom scopes** for multiple concurrent flows in the same tab
- **Always use type safety** with TypeScript generics
- **Trust automatic cleanup** - no need to manually clear contexts in `ngOnDestroy`
- **Check for null** when restoring contexts (they may not exist)
#### ❌ Don't
- **Don't store large objects** - keep contexts lean (URLs, IDs, simple flags)
- **Don't use for persistent data** - use NgRx or services for long-lived state
- **Don't store sensitive data** - contexts may be visible in browser dev tools
- **Don't manually clear in ngOnDestroy** - tab lifecycle handles cleanup automatically
- **Don't use for cross-tab communication** - use services or BroadcastChannel
### Cleanup Behavior
**Automatic Cleanup (Recommended):**
```typescript
// ✅ No manual cleanup needed - tab lifecycle handles it!
export class CustomerFlowComponent {
navState = inject(NavigationStateService);
async startFlow() {
this.navState.preserveContext({ returnUrl: '/home' });
// Context automatically cleaned up when tab closes
}
// No ngOnDestroy needed!
}
```
**Manual Cleanup (Rarely Needed):**
```typescript
// Use only if you need to explicitly clear contexts during tab lifecycle
export class ComplexFlowComponent {
navState = inject(NavigationStateService);
async cancelFlow() {
// Explicitly clear all contexts for this tab
const cleared = this.navState.clearScopeContexts();
console.log(`Cleared ${cleared} contexts`);
}
}
```
### Related Documentation
- **Full API Reference**: See [libs/core/navigation/README.md](../../libs/core/navigation/README.md) for complete documentation
- **Usage Patterns**: Detailed examples and patterns in the library README
- **Testing Guide**: Full testing guide included in the library documentation
- **Migration Guide**: Instructions for migrating from SessionStorage approach in the library README

View File

@@ -103,6 +103,41 @@
- **[Storybook](https://storybook.js.org/)**
- Isolated component development and living documentation environment.
## Core Libraries
### Navigation State Management
- **`@isa/core/navigation`**
- Type-safe navigation state management through Angular Router state
- Provides clean abstraction for passing temporary state between routes
- Eliminates URL pollution from query parameters
- Platform-agnostic using Angular's Location service
- Full documentation: [libs/core/navigation/README.md](../libs/core/navigation/README.md)
### Logging
- **`@isa/core/logging`**
- Centralized logging service for application-wide logging
- Provides contextual information for debugging
### Storage
- **`@isa/core/storage`**
- Storage providers for state persistence
- Session and local storage abstractions
### Tabs
- **`@isa/core/tabs`**
- Tab management and navigation history tracking
- Persistent tab state across sessions
### Configuration
- **`@isa/core/config`**
- Application configuration management
- Environment-specific settings
## Domain Libraries
### Customer Relationship Management (CRM)

View File

@@ -22,10 +22,14 @@
</div>
<div class="flex-grow"></div>
<div class="pt-5">
<ui-icon-button
<button
(click)="navigateToCustomer()"
uiIconButton
class="bg-isa-neutral-400"
name="isaActionEdit"
size="large"
color="secondary"
></ui-icon-button>
type="button"
>
</button>
</div>

View File

@@ -12,6 +12,8 @@ import { isaActionEdit } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { AddressComponent } from '@isa/shared/address';
import { injectTabId } from '@isa/core/tabs';
import { NavigationStateService } from '@isa/core/navigation';
@Component({
selector: 'checkout-billing-and-shipping-address-card',
@@ -22,6 +24,8 @@ import { AddressComponent } from '@isa/shared/address';
providers: [provideIcons({ isaActionEdit })],
})
export class BillingAndShippingAddressCardComponent {
#navigationState = inject(NavigationStateService);
tabId = injectTabId();
#customerResource = inject(SelectedCustomerResource).resource;
isLoading = this.#customerResource.isLoading;
@@ -30,6 +34,20 @@ export class BillingAndShippingAddressCardComponent {
return this.#customerResource.value();
});
async navigateToCustomer() {
const customerId = this.customer()?.id;
if (!customerId) return;
const returnUrl = `/${this.tabId()}/reward/cart`;
// Preserve context across intermediate navigations (auto-scoped to active tab)
await this.#navigationState.navigateWithPreservedContext(
['/', 'kunde', this.tabId(), 'customer', 'search', customerId],
{ returnUrl },
'select-customer',
);
}
payer = computed(() => {
return this.customer();
});

View File

@@ -8,9 +8,9 @@
}
.info-block--value {
@apply isa-text-body-2-regular;
@apply isa-text-body-2-bold;
}
.info-block--label {
@apply isa-text-body-2-bold;
@apply isa-text-body-2-regular;
}

View File

@@ -43,6 +43,7 @@ import {
styleUrls: ['./reward-shopping-cart-item-quantity-control.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [QuantityControlComponent, FormsModule],
exportAs: 'checkoutRewardShoppingCartItemQuantityControl',
})
export class RewardShoppingCartItemQuantityControlComponent {
#logger = logger(() => ({

View File

@@ -1,3 +1,3 @@
:host {
@apply flex flex-row gap-6 items-start p-6 bg-white rounded-2xl;
@apply flex flex-col gap-6 items-stretch p-6 bg-white rounded-2xl;
}

View File

@@ -1,25 +1,47 @@
@let itm = item();
<checkout-product-info-redemption
class="grow"
[item]="itm"
></checkout-product-info-redemption>
<div
class="flex flex-col gap-6 justify-between shrink grow-0 self-stretch w-[14.25rem]"
>
<div class="flex justify-end gap-4 mt-5">
<checkout-reward-shopping-cart-item-quantity-control
[item]="itm"
></checkout-reward-shopping-cart-item-quantity-control>
<checkout-reward-shopping-cart-item-remove-button
[item]="itm"
[(isBusy)]="isBusy"
></checkout-reward-shopping-cart-item-remove-button>
<div class="flex flex-row gap-6 items-start">
<checkout-product-info-redemption
class="grow"
[item]="itm"
[orientation]="isDesktop() ? 'horizontal' : 'vertical'"
></checkout-product-info-redemption>
@if (isDesktop()) {
<checkout-destination-info
[underline]="true"
class="cursor-pointer max-w-[14.25rem] grow-0 shrink-0"
(click)="updatePurchaseOption()"
[shoppingCartItem]="itm"
></checkout-destination-info>
}
<div
class="flex flex-col gap-6 justify-between shrink grow-0 self-stretch w-[14.25rem] isa-desktop:w-auto"
>
<div class="flex justify-end gap-4 mt-5">
<checkout-reward-shopping-cart-item-quantity-control
#quantityControl="checkoutRewardShoppingCartItemQuantityControl"
[item]="itm"
></checkout-reward-shopping-cart-item-quantity-control>
<checkout-reward-shopping-cart-item-remove-button
[item]="itm"
[(isBusy)]="isBusy"
></checkout-reward-shopping-cart-item-remove-button>
</div>
@if (!isDesktop()) {
<checkout-destination-info
[underline]="true"
class="cursor-pointer mt-4 max-w-[14.25rem] grow-0 shrink-0"
(click)="updatePurchaseOption()"
[shoppingCartItem]="itm"
></checkout-destination-info>
}
</div>
<div class="grow"></div>
<checkout-destination-info
[underline]="true"
class="cursor-pointer"
(click)="updatePurchaseOption()"
[shoppingCartItem]="itm"
></checkout-destination-info>
</div>
@if (quantityControl.maxQuantity() < 2) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
</div>
}

View File

@@ -6,6 +6,7 @@ import {
input,
signal,
} from '@angular/core';
import { breakpoint, Breakpoint } from '@isa/ui/layout';
import {
SelectedRewardShoppingCartResource,
ShoppingCartItem,
@@ -21,6 +22,8 @@ import { firstValueFrom } from 'rxjs';
import { RewardShoppingCartItemQuantityControlComponent } from './reward-shopping-cart-item-quantity-control.component';
import { RewardShoppingCartItemRemoveButtonComponent } from './reward-shopping-cart-item-remove-button.component';
import { StockResource } from '@isa/remission/data-access';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaOtherInfo } from '@isa/icons';
// TODO: [Next Sprint - Medium Priority] Create test file
// - Test component creation and item input binding
@@ -37,8 +40,9 @@ import { StockResource } from '@isa/remission/data-access';
DestinationInfoComponent,
RewardShoppingCartItemQuantityControlComponent,
RewardShoppingCartItemRemoveButtonComponent,
NgIcon,
],
providers: [StockResource],
providers: [StockResource, provideIcons({ isaOtherInfo })],
})
export class RewardShoppingCartItemComponent {
#logger = logger(() => ({ component: 'RewardShoppingCartItemComponent' }));
@@ -52,6 +56,8 @@ export class RewardShoppingCartItemComponent {
isBusy = signal(false);
isDesktop = breakpoint([Breakpoint.Desktop, Breakpoint.DekstopL, Breakpoint.DekstopXL]);
item = input.required<ShoppingCartItem>();
itemId = computed(() => this.item().id);

View File

@@ -1,3 +1,7 @@
:host {
@apply flex flex-col items-start gap-2 flex-grow;
}
.address-container {
@apply line-clamp-2 break-words text-ellipsis;
}

View File

@@ -11,12 +11,18 @@
orderType()
}}</span>
</div>
<div class="text-isa-neutral-600 isa-text-body-2-regular">
<div class="text-isa-neutral-600 isa-text-body-2-regular address-container">
@if (displayAddress()) {
{{ branchName() }} |
{{ name() }} |
<shared-inline-address [address]="address()"></shared-inline-address>
} @else if (estimatedDelivery()) {
Zustellung zwischen {{ estimatedDelivery().start | date: 'E, dd.MM.' }} und
{{ estimatedDelivery().stop | date: 'E, dd.MM.' }}
} @else {
@if (estimatedDelivery(); as delivery) {
@if (delivery.stop) {
Zustellung zwischen {{ delivery.start | date: 'E, dd.MM.' }} und
{{ delivery.stop | date: 'E, dd.MM.' }}
} @else {
Zustellung voraussichtlich am {{ delivery.start | date: 'E, dd.MM.' }}
}
}
}
</div>

View File

@@ -12,6 +12,7 @@ import {
OrderType,
ShoppingCartItem,
} from '@isa/checkout/data-access';
import { SelectedCustomerShippingAddressResource } from '@isa/crm/data-access';
import {
isaDeliveryVersand,
isaDeliveryRuecklage2,
@@ -34,10 +35,12 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion';
isaDeliveryRuecklage1,
}),
BranchResource,
SelectedCustomerShippingAddressResource,
],
})
export class DestinationInfoComponent {
#branchResource = inject(BranchResource);
#shippingAddressResource = inject(SelectedCustomerShippingAddressResource);
underline = input<boolean, unknown>(false, {
transform: coerceBooleanProperty,
@@ -75,7 +78,7 @@ export class DestinationInfoComponent {
return (
OrderType.InStore === orderType ||
OrderType.Pickup === orderType ||
OrderType.B2BShipping
OrderType.B2BShipping === orderType
);
});
@@ -96,16 +99,30 @@ export class DestinationInfoComponent {
}
});
branchName = computed(() => {
name = computed(() => {
const orderType = this.orderType();
if (
OrderType.Delivery === orderType ||
OrderType.B2BShipping === orderType ||
OrderType.DigitalShipping === orderType
) {
const shippingAddress = this.#shippingAddressResource.resource.value();
return `${shippingAddress?.firstName || ''} ${shippingAddress?.lastName || ''}`.trim();
}
return this.branch()?.name || 'Filiale nicht gefunden';
});
address = computed(() => {
const orderType = this.orderType();
if (OrderType.B2BShipping === orderType) {
// B2B shipping doesn't use branch address
return undefined;
if (
OrderType.Delivery === orderType ||
OrderType.B2BShipping === orderType ||
OrderType.DigitalShipping === orderType
) {
const shippingAddress = this.#shippingAddressResource.resource.value();
return shippingAddress?.address || undefined;
}
const destination = this.shoppingCartItem().destination;
@@ -113,6 +130,18 @@ export class DestinationInfoComponent {
});
estimatedDelivery = computed(() => {
return this.shoppingCartItem().availability?.estimatedDelivery;
const availability = this.shoppingCartItem().availability;
const estimatedDelivery = availability?.estimatedDelivery;
const estimatedShippingDate = availability?.estimatedShippingDate;
if (estimatedDelivery?.start && estimatedDelivery?.stop) {
return { start: estimatedDelivery.start, stop: estimatedDelivery.stop };
}
if (estimatedShippingDate) {
return { start: estimatedShippingDate, stop: null };
}
return null;
});
}

View File

@@ -1,7 +1,7 @@
@let prd = item().product;
@let rPoints = points();
@if (prd) {
<div class="grid grid-cols-[auto,1fr] gap-6">
<div class="grid grid-cols-[auto,1fr] gap-6 items-start">
<div>
<img
sharedProductRouterLink
@@ -13,7 +13,7 @@
/>
</div>
<div class="flex flex-1 flex-col justify-between gap-1">
<div class="flex flex-1 flex-col gap-1">
<div class="isa-text-body-2-bold">{{ prd.contributors }}</div>
<div
[class.isa-text-body-2-regular]="orientation() === 'horizontal'"
@@ -28,7 +28,7 @@
</div>
</div>
<div
class="flex flex-1 flex-col justify-between gap-1"
class="flex flex-1 flex-col gap-2"
[class.ml-20]="orientation() === 'vertical'"
>
<shared-product-format
@@ -36,8 +36,11 @@
[formatDetail]="prd.formatDetail"
[formatDetailsBold]="true"
></shared-product-format>
<div class="isa-text-body-2-regular text-neutral-600">
{{ prd.manufacturer }} | {{ prd.ean }}
<div
class="flex items-center gap-1 isa-text-body-2-regular text-neutral-600"
>
<span class="truncate">{{ prd.manufacturer }}</span>
<span class="shrink-0">| {{ prd.ean }}</span>
</div>
<div class="isa-text-body-2-regular text-neutral-600">
{{ prd.publicationDate | date: 'dd. MMMM yyyy' }}

View File

@@ -0,0 +1,792 @@
# @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
---
#### `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 |
| `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.

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'core',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'core',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "core-navigation",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/core/navigation/src",
"prefix": "core",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/core/navigation"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,5 @@
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';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,102 @@
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>;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

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

View File

@@ -3,10 +3,10 @@ import { StorageProvider } from './storage-provider';
@Injectable({ providedIn: 'root' })
export class SessionStorageProvider implements StorageProvider {
async set(key: string, value: unknown): Promise<void> {
set(key: string, value: unknown): void {
sessionStorage.setItem(key, JSON.stringify(value));
}
async get(key: string): Promise<unknown> {
get(key: string): unknown {
const data = sessionStorage.getItem(key);
if (data) {
return JSON.parse(data);
@@ -14,7 +14,7 @@ export class SessionStorageProvider implements StorageProvider {
return data;
}
async clear(key: string): Promise<void> {
clear(key: string): void {
sessionStorage.removeItem(key);
}
}

View File

@@ -4,3 +4,4 @@ export * from './country';
export * from './customer-type';
export * from './customer.model';
export * from './payer';
export * from './shipping-address.model';

View File

@@ -0,0 +1,3 @@
import { ShippingAddressDTO } from '@generated/swagger/crm-api';
export type ShippingAddress = ShippingAddressDTO;

View File

@@ -0,0 +1,54 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService, ShippingAddressService } from '../services';
import { TabService } from '@isa/core/tabs';
import { ShippingAddress } from '../models';
@Injectable()
export class CustomerShippingAddressResource {
#shippingAddressService = inject(ShippingAddressService);
#params = signal<{
shippingAddressId: number | undefined;
}>({
shippingAddressId: undefined,
});
params(params: { shippingAddressId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<ShippingAddress | undefined> => {
if (!params.shippingAddressId) {
return undefined;
}
const res = await this.#shippingAddressService.fetchShippingAddress(
{
shippingAddressId: params.shippingAddressId,
},
abortSignal,
);
return res.result as ShippingAddress;
},
});
}
@Injectable()
export class SelectedCustomerShippingAddressResource extends CustomerShippingAddressResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const shippingAddressId = tabId
? this.#customerMetadata.selectedShippingAddressId(tabId)
: undefined;
this.params({ shippingAddressId });
});
}
}

View File

@@ -0,0 +1,58 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService, ShippingAddressService } from '../services';
import { TabService } from '@isa/core/tabs';
import { ShippingAddress } from '../models';
@Injectable()
export class CustomerShippingAddressesResource {
#shippingAddressService = inject(ShippingAddressService);
#params = signal<{
customerId: number | undefined;
take?: number | null;
skip?: number | null;
}>({
customerId: undefined,
});
params(params: { customerId?: number; take?: number | null; skip?: number | null }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<ShippingAddress[] | undefined> => {
if (!params.customerId) {
return undefined;
}
const res = await this.#shippingAddressService.fetchCustomerShippingAddresses(
{
customerId: params.customerId,
take: params.take,
skip: params.skip,
},
abortSignal,
);
return res.result as ShippingAddress[];
},
});
}
@Injectable()
export class SelectedCustomerShippingAddressesResource extends CustomerShippingAddressesResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const customerId = tabId
? this.#customerMetadata.selectedCustomerId(tabId)
: undefined;
this.params({ customerId });
});
}
}

View File

@@ -1,3 +1,5 @@
export * from './country.resource';
export * from './customer-bonus-cards.resource';
export * from './customer-shipping-address.resource';
export * from './customer-shipping-addresses.resource';
export * from './customer.resource';

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const FetchCustomerShippingAddressesSchema = z.object({
customerId: z.number().int(),
take: z.number().int().optional().nullable(),
skip: z.number().int().optional().nullable(),
});
export type FetchCustomerShippingAddresses = z.infer<typeof FetchCustomerShippingAddressesSchema>;
export type FetchCustomerShippingAddressesInput = z.input<typeof FetchCustomerShippingAddressesSchema>;

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const FetchShippingAddressSchema = z.object({
shippingAddressId: z.number().int(),
});
export type FetchShippingAddress = z.infer<typeof FetchShippingAddressSchema>;
export type FetchShippingAddressInput = z.input<typeof FetchShippingAddressSchema>;

View File

@@ -1,2 +1,4 @@
export * from './fetch-customer-cards.schema';
export * from './fetch-customer-shipping-addresses.schema';
export * from './fetch-customer.schema';
export * from './fetch-shipping-address.schema';

View File

@@ -1,3 +1,4 @@
export * from './country.service';
export * from './crm-search.service';
export * from './crm-tab-metadata.service';
export * from './shipping-address.service';

View File

@@ -0,0 +1,79 @@
import { inject, Injectable } from '@angular/core';
import { ShippingAddressService as GeneratedShippingAddressService } from '@generated/swagger/crm-api';
import {
FetchCustomerShippingAddressesInput,
FetchCustomerShippingAddressesSchema,
FetchShippingAddressInput,
FetchShippingAddressSchema,
} from '../schemas';
import {
catchResponseArgsErrorPipe,
ListResponseArgs,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { ShippingAddress } from '../models';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class ShippingAddressService {
#shippingAddressService = inject(GeneratedShippingAddressService);
#logger = logger(() => ({
service: 'ShippingAddressService',
}));
async fetchCustomerShippingAddresses(
params: FetchCustomerShippingAddressesInput,
abortSignal?: AbortSignal,
): Promise<ListResponseArgs<ShippingAddress>> {
this.#logger.info('Fetching customer shipping addresses from API');
const { customerId, take, skip } =
FetchCustomerShippingAddressesSchema.parse(params);
let req$ = this.#shippingAddressService
.ShippingAddressGetShippingAddresses({ customerId, take, skip })
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched customer shipping addresses');
return res as ListResponseArgs<ShippingAddress>;
} catch (error) {
this.#logger.error('Error fetching customer shipping addresses', error);
return {
result: [],
totalCount: 0,
} as unknown as ListResponseArgs<ShippingAddress>;
}
}
async fetchShippingAddress(
params: FetchShippingAddressInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<ShippingAddress>> {
this.#logger.info('Fetching shipping address from API');
const { shippingAddressId } = FetchShippingAddressSchema.parse(params);
let req$ = this.#shippingAddressService
.ShippingAddressGetShippingaddress(shippingAddressId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched shipping address');
return res as ResponseArgs<ShippingAddress>;
} catch (error) {
this.#logger.error('Error fetching shipping address', error);
return undefined as unknown as ResponseArgs<ShippingAddress>;
}
}
}

View File

@@ -58,6 +58,7 @@
"@isa/common/print": ["libs/common/print/src/index.ts"],
"@isa/core/config": ["libs/core/config/src/index.ts"],
"@isa/core/logging": ["libs/core/logging/src/index.ts"],
"@isa/core/navigation": ["libs/core/navigation/src/index.ts"],
"@isa/core/notifications": ["libs/core/notifications/src/index.ts"],
"@isa/core/storage": ["libs/core/storage/src/index.ts"],
"@isa/core/tabs": ["libs/core/tabs/src/index.ts"],