diff --git a/CLAUDE.md b/CLAUDE.md index 3545cd5a5..826e14290 100644 --- a/CLAUDE.md +++ b/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 diff --git a/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.html b/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.html index d30b56974..b121a8e6f 100644 --- a/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.html +++ b/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.html @@ -1,213 +1,224 @@ - -
-
-
- -
-
-

- {{ (isBusinessKonto$ | async) ? 'Firmendetails' : 'Kundendetails' }} -

-

Sind Ihre Kundendaten korrekt?

-
-
-
-
- - - {{ customerType$ | async }} - -
- @if (showEditButton$ | async) { - @if (editRoute$ | async; as editRoute) { - - Bearbeiten - - } - } -
- -
-
-
Erstellungsdatum
- @if (created$ | async; as created) { -
- {{ created | date: 'dd.MM.yyyy' }} | - {{ created | date: 'HH:mm' }} Uhr -
- } -
-
-
Kundennummer
-
{{ customerNumber$ | async }}
-
- @if (customerNumberDig$ | async; as customerNumberDig) { -
-
Kundennummer-DIG
-
{{ customerNumberDig }}
-
- } - @if (customerNumberBeeline$ | async; as customerNumberBeeline) { -
-
Kundennummer-BEELINE
-
{{ customerNumberBeeline }}
-
- } -
- @if (isBusinessKonto$ | async) { -
-
Firmenname
-
{{ organisationName$ | async }}
-
-
-
Abteilung
-
{{ department$ | async }}
-
-
-
USt-ID
-
{{ vatId$ | async }}
-
- } - -
-
Anrede
-
{{ gender$ | async }}
-
-
-
Titel
-
{{ title$ | async }}
-
-
-
Nachname
-
{{ lastName$ | async }}
-
-
-
Vorname
-
{{ firstName$ | async }}
-
-
-
E-Mail
-
{{ email$ | async }}
-
-
-
Straße
-
{{ street$ | async }}
-
-
-
Hausnr.
-
{{ streetNumber$ | async }}
-
-
-
PLZ
-
{{ zipCode$ | async }}
-
-
-
Ort
-
{{ city$ | async }}
-
-
-
Adresszusatz
-
{{ info$ | async }}
-
-
-
Land
- @if (country$ | async; as country) { -
{{ country | country }}
- } -
-
-
Festnetznr.
-
{{ landline$ | async }}
-
-
-
Mobilnr.
-
{{ mobile$ | async }}
-
- - @if (!(isBusinessKonto$ | async)) { -
-
Geburtstag
-
- {{ dateOfBirth$ | async | date: 'dd.MM.yyyy' }} -
-
- } - @if (!(isBusinessKonto$ | async) && (organisationName$ | async)) { -
-
Firmenname
-
{{ organisationName$ | async }}
-
- @if (!(isOnlineOrCustomerCardUser$ | async)) { -
-
Abteilung
-
{{ department$ | async }}
-
-
-
USt-ID
-
{{ vatId$ | async }}
-
- } - } - - - -
-
-
- -@if (!isRewardTab()) { - @if (shoppingCartHasNoItems$ | async) { - - } - - @if (shoppingCartHasItems$ | async) { - - } -} @else { - -} + +
+
+
+ +
+
+

+ {{ (isBusinessKonto$ | async) ? 'Firmendetails' : 'Kundendetails' }} +

+

Sind Ihre Kundendaten korrekt?

+
+
+
+
+ + + {{ customerType$ | async }} + +
+ @if (showEditButton$ | async) { + @if (editRoute$ | async; as editRoute) { + + Bearbeiten + + } + } +
+ +
+
+
Erstellungsdatum
+ @if (created$ | async; as created) { +
+ {{ created | date: 'dd.MM.yyyy' }} | + {{ created | date: 'HH:mm' }} Uhr +
+ } +
+
+
Kundennummer
+
{{ customerNumber$ | async }}
+
+ @if (customerNumberDig$ | async; as customerNumberDig) { +
+
Kundennummer-DIG
+
{{ customerNumberDig }}
+
+ } + @if (customerNumberBeeline$ | async; as customerNumberBeeline) { +
+
Kundennummer-BEELINE
+
{{ customerNumberBeeline }}
+
+ } +
+ @if (isBusinessKonto$ | async) { +
+
Firmenname
+
{{ organisationName$ | async }}
+
+
+
Abteilung
+
{{ department$ | async }}
+
+
+
USt-ID
+
{{ vatId$ | async }}
+
+ } + +
+
Anrede
+
{{ gender$ | async }}
+
+
+
Titel
+
{{ title$ | async }}
+
+
+
Nachname
+
{{ lastName$ | async }}
+
+
+
Vorname
+
{{ firstName$ | async }}
+
+
+
E-Mail
+
{{ email$ | async }}
+
+
+
Straße
+
{{ street$ | async }}
+
+
+
Hausnr.
+
{{ streetNumber$ | async }}
+
+
+
PLZ
+
{{ zipCode$ | async }}
+
+
+
Ort
+
{{ city$ | async }}
+
+
+
Adresszusatz
+
{{ info$ | async }}
+
+
+
Land
+ @if (country$ | async; as country) { +
{{ country | country }}
+ } +
+
+
Festnetznr.
+
{{ landline$ | async }}
+
+
+
Mobilnr.
+
{{ mobile$ | async }}
+
+ + @if (!(isBusinessKonto$ | async)) { +
+
Geburtstag
+
+ {{ dateOfBirth$ | async | date: 'dd.MM.yyyy' }} +
+
+ } + @if (!(isBusinessKonto$ | async) && (organisationName$ | async)) { +
+
Firmenname
+
{{ organisationName$ | async }}
+
+ @if (!(isOnlineOrCustomerCardUser$ | async)) { +
+
Abteilung
+
{{ department$ | async }}
+
+
+
USt-ID
+
{{ vatId$ | async }}
+
+ } + } + + + +
+
+
+ +@if (hasReturnUrl()) { + +} @else if (!isRewardTab()) { + @if (shoppingCartHasNoItems$ | async) { + + } + + @if (shoppingCartHasItems$ | async) { + + } +} @else { + +} diff --git a/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts b/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts index 66796ce09..1e357e1e5 100644 --- a/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts +++ b/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts @@ -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(); 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 { + // 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 { + 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 diff --git a/apps/isa-app/src/shared/components/searchbox/searchbox.component.html b/apps/isa-app/src/shared/components/searchbox/searchbox.component.html index 3a85bb546..a58aafa93 100644 --- a/apps/isa-app/src/shared/components/searchbox/searchbox.component.html +++ b/apps/isa-app/src/shared/components/searchbox/searchbox.component.html @@ -1,73 +1,84 @@ -
-
- - @if (showHint) { -
- {{ hint }} -
- } -
- @if (input.value) { - - } - @if (!loading) { - @if (!showScannerButton) { - - } - @if (showScannerButton) { - - } - } - @if (loading) { -
- -
- } -
- + + diff --git a/docs/guidelines/state-management.md b/docs/guidelines/state-management.md index f2dc908c7..57bd2c6e2 100644 --- a/docs/guidelines/state-management.md +++ b/docs/guidelines/state-management.md @@ -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(); + + 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()) { + +} +``` + +### 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 diff --git a/docs/tech-stack.md b/docs/tech-stack.md index cc22171b8..586f402d4 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -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) diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.html b/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.html index 418ab5170..d7c66e4d0 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.html +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.html @@ -22,10 +22,14 @@
- + type="button" + > +
diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.ts b/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.ts index 7620143d5..4844b9b05 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.ts +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/billing-and-shipping-address-card/billing-and-shipping-address-card.component.ts @@ -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(); }); diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/customer-reward-card/customer-reward-card.component.css b/libs/checkout/feature/reward-shopping-cart/src/lib/customer-reward-card/customer-reward-card.component.css index 8a6a73565..4e57611f6 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/customer-reward-card/customer-reward-card.component.css +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/customer-reward-card/customer-reward-card.component.css @@ -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; } diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item-quantity-control.component.ts b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item-quantity-control.component.ts index 2ef1260d5..d620e2560 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item-quantity-control.component.ts +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item-quantity-control.component.ts @@ -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(() => ({ diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.css b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.css index 4c3a9a300..1fcf0aca5 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.css +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.css @@ -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; } diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.html b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.html index 6b137a9c9..a4aca6a85 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.html +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.html @@ -1,25 +1,47 @@ @let itm = item(); - -
-
- - +
+ + @if (isDesktop()) { + + } +
+
+ + +
+ @if (!isDesktop()) { + + }
-
-
+ +@if (quantityControl.maxQuantity() < 2) { +
+ +
Geringer Bestand - Artikel holen vor Abschluss
+
+} diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.ts b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.ts index 6a58b4869..531661c8d 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.ts +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.ts @@ -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(); itemId = computed(() => this.item().id); diff --git a/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.css b/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.css index b029d2738..3803d0775 100644 --- a/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.css +++ b/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.css @@ -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; +} diff --git a/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.html b/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.html index cdd268009..ed88c28ae 100644 --- a/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.html +++ b/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.html @@ -11,12 +11,18 @@ orderType() }}
-
+
@if (displayAddress()) { - {{ branchName() }} | + {{ name() }} | - } @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.' }} + } + } }
diff --git a/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.ts b/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.ts index 10ba8a649..083bc06ba 100644 --- a/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.ts +++ b/libs/checkout/shared/product-info/src/lib/destination-info/destination-info.component.ts @@ -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(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; }); } diff --git a/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.html b/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.html index caa678926..90ea3a978 100644 --- a/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.html +++ b/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.html @@ -1,7 +1,7 @@ @let prd = item().product; @let rPoints = points(); @if (prd) { -
+
-
+
{{ prd.contributors }}
-
- {{ prd.manufacturer }} | {{ prd.ean }} +
+ {{ prd.manufacturer }} + | {{ prd.ean }}
{{ prd.publicationDate | date: 'dd. MMMM yyyy' }} diff --git a/libs/core/navigation/README.md b/libs/core/navigation/README.md new file mode 100644 index 000000000..2ac555732 --- /dev/null +++ b/libs/core/navigation/README.md @@ -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: `` +}) +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: `` +}) +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(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(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(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({ + returnUrl: '/reward/cart', + selectedItems: [1, 2, 3], + customerId: 456, + metadata: { + source: 'reward', + timestamp: Date.now() + } +}); + +// Restore with type safety +const context = navState.restoreAndClearContext(); +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; + 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 (``) +- **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. diff --git a/libs/core/navigation/eslint.config.cjs b/libs/core/navigation/eslint.config.cjs new file mode 100644 index 000000000..bdab98018 --- /dev/null +++ b/libs/core/navigation/eslint.config.cjs @@ -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: {}, + }, +]; diff --git a/libs/core/navigation/project.json b/libs/core/navigation/project.json new file mode 100644 index 000000000..e9a6243fe --- /dev/null +++ b/libs/core/navigation/project.json @@ -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" + } + } +} diff --git a/libs/core/navigation/src/index.ts b/libs/core/navigation/src/index.ts new file mode 100644 index 000000000..6a10b041d --- /dev/null +++ b/libs/core/navigation/src/index.ts @@ -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'; diff --git a/libs/core/navigation/src/lib/navigation-context.constants.ts b/libs/core/navigation/src/lib/navigation-context.constants.ts new file mode 100644 index 000000000..b23d06293 --- /dev/null +++ b/libs/core/navigation/src/lib/navigation-context.constants.ts @@ -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'; diff --git a/libs/core/navigation/src/lib/navigation-context.service.spec.ts b/libs/core/navigation/src/lib/navigation-context.service.spec.ts new file mode 100644 index 000000000..c24dff77c --- /dev/null +++ b/libs/core/navigation/src/lib/navigation-context.service.spec.ts @@ -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>; + entityMap: ReturnType; + patchTabMetadata: ReturnType; + }; + + beforeEach(() => { + // Create mock TabService with signals and methods + tabServiceMock = { + activatedTabId: signal(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(); + + // 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(); + + // 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); + }); + }); +}); diff --git a/libs/core/navigation/src/lib/navigation-context.service.ts b/libs/core/navigation/src/lib/navigation-context.service.ts new file mode 100644 index 000000000..4ee947cf8 --- /dev/null +++ b/libs/core/navigation/src/lib/navigation-context.service.ts @@ -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 { + 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; + } + + /** + * 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, + ): 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( + data: T, + customScope?: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _ttl?: number, // Kept for API compatibility but ignored + ): Promise { + 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( + customScope?: string, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const tabId = this.#tabService.activatedTabId(); + if (tabId === null) { + return 0; + } + + const contextsMap = this.#getContextsMap(tabId); + return Object.keys(contextsMap).length; + } +} diff --git a/libs/core/navigation/src/lib/navigation-context.types.ts b/libs/core/navigation/src/lib/navigation-context.types.ts new file mode 100644 index 000000000..bada5b424 --- /dev/null +++ b/libs/core/navigation/src/lib/navigation-context.types.ts @@ -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; +} diff --git a/libs/core/navigation/src/lib/navigation-state.service.spec.ts b/libs/core/navigation/src/lib/navigation-state.service.spec.ts new file mode 100644 index 000000000..db689b3e0 --- /dev/null +++ b/libs/core/navigation/src/lib/navigation-state.service.spec.ts @@ -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 }; + let routerMock: { navigate: ReturnType }; + let contextServiceMock: { + setContext: ReturnType; + getContext: ReturnType; + getAndClearContext: ReturnType; + clearContext: ReturnType; + hasContext: ReturnType; + clearScope: ReturnType; + }; + + 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('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(); + + 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('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); + }); + }); +}); diff --git a/libs/core/navigation/src/lib/navigation-state.service.ts b/libs/core/navigation/src/lib/navigation-state.service.ts new file mode 100644 index 000000000..e25bc34d0 --- /dev/null +++ b/libs/core/navigation/src/lib/navigation-state.service.ts @@ -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( + state: T, + customScope?: string, + ): Promise { + 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( + customScope?: string, + ): Promise { + return await this.#contextService.getContext(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 { + return await this.#contextService.getAndClearContext(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 { + 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 { + 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( + 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 { + return await this.#contextService.clearScope(); + } +} diff --git a/libs/core/navigation/src/lib/navigation-state.types.ts b/libs/core/navigation/src/lib/navigation-state.types.ts new file mode 100644 index 000000000..13dc096fd --- /dev/null +++ b/libs/core/navigation/src/lib/navigation-state.types.ts @@ -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; +} diff --git a/libs/core/navigation/src/test-setup.ts b/libs/core/navigation/src/test-setup.ts new file mode 100644 index 000000000..cebf5ae72 --- /dev/null +++ b/libs/core/navigation/src/test-setup.ts @@ -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(), +); diff --git a/libs/core/navigation/tsconfig.json b/libs/core/navigation/tsconfig.json new file mode 100644 index 000000000..3268ed4dc --- /dev/null +++ b/libs/core/navigation/tsconfig.json @@ -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" + } + ] +} diff --git a/libs/core/navigation/tsconfig.lib.json b/libs/core/navigation/tsconfig.lib.json new file mode 100644 index 000000000..312ee86bb --- /dev/null +++ b/libs/core/navigation/tsconfig.lib.json @@ -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"] +} diff --git a/libs/core/navigation/tsconfig.spec.json b/libs/core/navigation/tsconfig.spec.json new file mode 100644 index 000000000..5785a8a5f --- /dev/null +++ b/libs/core/navigation/tsconfig.spec.json @@ -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"] +} diff --git a/libs/core/navigation/vite.config.mts b/libs/core/navigation/vite.config.mts new file mode 100644 index 000000000..18ed5d6b9 --- /dev/null +++ b/libs/core/navigation/vite.config.mts @@ -0,0 +1,33 @@ +/// +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'], + }, + }, +})); diff --git a/libs/core/storage/src/lib/storage-providers/session.storage-provider.ts b/libs/core/storage/src/lib/storage-providers/session.storage-provider.ts index b2d730729..fab17d60c 100644 --- a/libs/core/storage/src/lib/storage-providers/session.storage-provider.ts +++ b/libs/core/storage/src/lib/storage-providers/session.storage-provider.ts @@ -3,10 +3,10 @@ import { StorageProvider } from './storage-provider'; @Injectable({ providedIn: 'root' }) export class SessionStorageProvider implements StorageProvider { - async set(key: string, value: unknown): Promise { + set(key: string, value: unknown): void { sessionStorage.setItem(key, JSON.stringify(value)); } - async get(key: string): Promise { + 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 { + clear(key: string): void { sessionStorage.removeItem(key); } } diff --git a/libs/crm/data-access/src/lib/models/index.ts b/libs/crm/data-access/src/lib/models/index.ts index 7b1022853..832153a29 100644 --- a/libs/crm/data-access/src/lib/models/index.ts +++ b/libs/crm/data-access/src/lib/models/index.ts @@ -4,3 +4,4 @@ export * from './country'; export * from './customer-type'; export * from './customer.model'; export * from './payer'; +export * from './shipping-address.model'; diff --git a/libs/crm/data-access/src/lib/models/shipping-address.model.ts b/libs/crm/data-access/src/lib/models/shipping-address.model.ts new file mode 100644 index 000000000..a0841926f --- /dev/null +++ b/libs/crm/data-access/src/lib/models/shipping-address.model.ts @@ -0,0 +1,3 @@ +import { ShippingAddressDTO } from '@generated/swagger/crm-api'; + +export type ShippingAddress = ShippingAddressDTO; diff --git a/libs/crm/data-access/src/lib/resources/customer-shipping-address.resource.ts b/libs/crm/data-access/src/lib/resources/customer-shipping-address.resource.ts new file mode 100644 index 000000000..44d5c34cc --- /dev/null +++ b/libs/crm/data-access/src/lib/resources/customer-shipping-address.resource.ts @@ -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 => { + 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 }); + }); + } +} diff --git a/libs/crm/data-access/src/lib/resources/customer-shipping-addresses.resource.ts b/libs/crm/data-access/src/lib/resources/customer-shipping-addresses.resource.ts new file mode 100644 index 000000000..a04d1ed7e --- /dev/null +++ b/libs/crm/data-access/src/lib/resources/customer-shipping-addresses.resource.ts @@ -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 => { + 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 }); + }); + } +} diff --git a/libs/crm/data-access/src/lib/resources/index.ts b/libs/crm/data-access/src/lib/resources/index.ts index 6d09a3b33..3e168dc6e 100644 --- a/libs/crm/data-access/src/lib/resources/index.ts +++ b/libs/crm/data-access/src/lib/resources/index.ts @@ -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'; diff --git a/libs/crm/data-access/src/lib/schemas/fetch-customer-shipping-addresses.schema.ts b/libs/crm/data-access/src/lib/schemas/fetch-customer-shipping-addresses.schema.ts new file mode 100644 index 000000000..f8dfa2445 --- /dev/null +++ b/libs/crm/data-access/src/lib/schemas/fetch-customer-shipping-addresses.schema.ts @@ -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; +export type FetchCustomerShippingAddressesInput = z.input; diff --git a/libs/crm/data-access/src/lib/schemas/fetch-shipping-address.schema.ts b/libs/crm/data-access/src/lib/schemas/fetch-shipping-address.schema.ts new file mode 100644 index 000000000..8f34ef34e --- /dev/null +++ b/libs/crm/data-access/src/lib/schemas/fetch-shipping-address.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const FetchShippingAddressSchema = z.object({ + shippingAddressId: z.number().int(), +}); + +export type FetchShippingAddress = z.infer; +export type FetchShippingAddressInput = z.input; diff --git a/libs/crm/data-access/src/lib/schemas/index.ts b/libs/crm/data-access/src/lib/schemas/index.ts index f5f8c7da0..3d0c1d901 100644 --- a/libs/crm/data-access/src/lib/schemas/index.ts +++ b/libs/crm/data-access/src/lib/schemas/index.ts @@ -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'; diff --git a/libs/crm/data-access/src/lib/services/index.ts b/libs/crm/data-access/src/lib/services/index.ts index de716db44..bd55c18f9 100644 --- a/libs/crm/data-access/src/lib/services/index.ts +++ b/libs/crm/data-access/src/lib/services/index.ts @@ -1,3 +1,4 @@ export * from './country.service'; export * from './crm-search.service'; export * from './crm-tab-metadata.service'; +export * from './shipping-address.service'; diff --git a/libs/crm/data-access/src/lib/services/shipping-address.service.ts b/libs/crm/data-access/src/lib/services/shipping-address.service.ts new file mode 100644 index 000000000..d3bcdc762 --- /dev/null +++ b/libs/crm/data-access/src/lib/services/shipping-address.service.ts @@ -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> { + 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; + } catch (error) { + this.#logger.error('Error fetching customer shipping addresses', error); + return { + result: [], + totalCount: 0, + } as unknown as ListResponseArgs; + } + } + + async fetchShippingAddress( + params: FetchShippingAddressInput, + abortSignal?: AbortSignal, + ): Promise> { + 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; + } catch (error) { + this.#logger.error('Error fetching shipping address', error); + return undefined as unknown as ResponseArgs; + } + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json index e37159182..cbc97288c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"],