mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
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:
committed by
Nino Righi
parent
f15848d5c0
commit
596ae1da1b
19
CLAUDE.md
19
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
792
libs/core/navigation/README.md
Normal file
792
libs/core/navigation/README.md
Normal 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.
|
||||
34
libs/core/navigation/eslint.config.cjs
Normal file
34
libs/core/navigation/eslint.config.cjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
20
libs/core/navigation/project.json
Normal file
20
libs/core/navigation/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
libs/core/navigation/src/index.ts
Normal file
5
libs/core/navigation/src/index.ts
Normal 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';
|
||||
22
libs/core/navigation/src/lib/navigation-context.constants.ts
Normal file
22
libs/core/navigation/src/lib/navigation-context.constants.ts
Normal 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';
|
||||
668
libs/core/navigation/src/lib/navigation-context.service.spec.ts
Normal file
668
libs/core/navigation/src/lib/navigation-context.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
373
libs/core/navigation/src/lib/navigation-context.service.ts
Normal file
373
libs/core/navigation/src/lib/navigation-context.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
102
libs/core/navigation/src/lib/navigation-context.types.ts
Normal file
102
libs/core/navigation/src/lib/navigation-context.types.ts
Normal 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>;
|
||||
}
|
||||
227
libs/core/navigation/src/lib/navigation-state.service.spec.ts
Normal file
227
libs/core/navigation/src/lib/navigation-state.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
287
libs/core/navigation/src/lib/navigation-state.service.ts
Normal file
287
libs/core/navigation/src/lib/navigation-state.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
23
libs/core/navigation/src/lib/navigation-state.types.ts
Normal file
23
libs/core/navigation/src/lib/navigation-state.types.ts
Normal 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;
|
||||
}
|
||||
13
libs/core/navigation/src/test-setup.ts
Normal file
13
libs/core/navigation/src/test-setup.ts
Normal 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(),
|
||||
);
|
||||
30
libs/core/navigation/tsconfig.json
Normal file
30
libs/core/navigation/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/core/navigation/tsconfig.lib.json
Normal file
27
libs/core/navigation/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
29
libs/core/navigation/tsconfig.spec.json
Normal file
29
libs/core/navigation/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
33
libs/core/navigation/vite.config.mts
Normal file
33
libs/core/navigation/vite.config.mts
Normal 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'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './country';
|
||||
export * from './customer-type';
|
||||
export * from './customer.model';
|
||||
export * from './payer';
|
||||
export * from './shipping-address.model';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ShippingAddressDTO } from '@generated/swagger/crm-api';
|
||||
|
||||
export type ShippingAddress = ShippingAddressDTO;
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './country.service';
|
||||
export * from './crm-search.service';
|
||||
export * from './crm-tab-metadata.service';
|
||||
export * from './shipping-address.service';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user