diff --git a/apps/isa-app/src/page/customer/components/kundenkarte/kundenkarte.component.ts b/apps/isa-app/src/page/customer/components/kundenkarte/kundenkarte.component.ts index 637078d0d..f0f28f7ac 100644 --- a/apps/isa-app/src/page/customer/components/kundenkarte/kundenkarte.component.ts +++ b/apps/isa-app/src/page/customer/components/kundenkarte/kundenkarte.component.ts @@ -9,8 +9,7 @@ import { DecimalPipe } from '@angular/common'; import { Component, Input, OnInit, inject } from '@angular/core'; import { IconComponent } from '@shared/components/icon'; import { BonusCardInfoDTO } from '@generated/swagger/crm-api'; -import { injectTabId } from '@isa/core/tabs'; -import { NavigationStateService } from '@isa/core/navigation'; +import { injectTabId, TabService } from '@isa/core/tabs'; import { Router } from '@angular/router'; import { CustomerSearchNavigation } from '@shared/services/navigation'; @@ -47,7 +46,7 @@ import { CustomerSearchNavigation } from '@shared/services/navigation'; export class KundenkarteComponent implements OnInit { #tabId = injectTabId(); #router = inject(Router); - #navigationState = inject(NavigationStateService); + #tabService = inject(TabService); #customerNavigationService = inject(CustomerSearchNavigation); @Input() cardDetails: BonusCardInfoDTO; @@ -69,13 +68,12 @@ export class KundenkarteComponent implements OnInit { return; } - this.#navigationState.preserveContext( - { + this.#tabService.patchTabMetadata(tabId, { + 'select-customer': { returnUrl: `/${tabId}/reward`, autoTriggerContinueFn: true, }, - 'select-customer', - ); + }); await this.#router.navigate( this.#customerNavigationService.detailsRoute({ diff --git a/apps/isa-app/src/page/customer/customer-page.component.ts b/apps/isa-app/src/page/customer/customer-page.component.ts index 429a24b24..c45d58297 100644 --- a/apps/isa-app/src/page/customer/customer-page.component.ts +++ b/apps/isa-app/src/page/customer/customer-page.component.ts @@ -1,5 +1,11 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { + Component, + ChangeDetectionStrategy, + OnDestroy, + inject, +} from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { TabService } from '@isa/core/tabs'; import { map } from 'rxjs/operators'; @Component({ @@ -9,8 +15,17 @@ import { map } from 'rxjs/operators'; changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, }) -export class CustomerComponent { +export class CustomerComponent implements OnDestroy { + private _tabService = inject(TabService); processId$ = this._activatedRoute.data.pipe(map((data) => data.processId)); constructor(private _activatedRoute: ActivatedRoute) {} + + ngOnDestroy() { + const tab = this._tabService.activatedTab(); + // #5512 Always clear preserved select-customer context if navigating out of customer area + this._tabService.patchTabMetadata(tab.id, { + 'select-customer': null, + }); + } } 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 26c462147..a5b9dc8cc 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 @@ -49,9 +49,14 @@ import { NavigateAfterRewardSelection, RewardSelectionPopUpService, } from '@isa/checkout/shared/reward-selection-dialog'; -import { NavigationStateService } from '@isa/core/navigation'; +import { TabService } from '@isa/core/tabs'; import { ShippingAddressDTO as CrmShippingAddressDTO } from '@generated/swagger/crm-api'; +interface SelectCustomerContext { + returnUrl?: string; + autoTriggerContinueFn?: boolean; +} + export interface CustomerDetailsViewMainState { isBusy: boolean; shoppingCart: ShoppingCartDTO; @@ -80,7 +85,7 @@ export class CustomerDetailsViewMainComponent private _router = inject(Router); private _activatedRoute = inject(ActivatedRoute); private _genderSettings = inject(GenderSettingsService); - private _navigationState = inject(NavigationStateService); + private _tabService = inject(TabService); private _onDestroy$ = new Subject(); customerService = inject(CrmCustomerService); @@ -97,18 +102,19 @@ 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'); + getReturnUrlFromContext(): string | null { + // Get from preserved context (survives intermediate navigations, scoped to tab) + const context = this._tabService.activatedTab()?.metadata?.[ + 'select-customer' + ] as SelectCustomerContext | undefined; return context?.returnUrl ?? null; } - async checkHasReturnUrl(): Promise { - const hasContext = - await this._navigationState.hasPreservedContext('select-customer'); + checkHasReturnUrl(): void { + const hasContext = !!this._tabService.activatedTab()?.metadata?.[ + 'select-customer' + ]; this.hasReturnUrl.set(hasContext); } @@ -321,24 +327,23 @@ export class CustomerDetailsViewMainComponent ngOnInit() { // Check if we have a return URL context - this.checkHasReturnUrl().then(async () => { - // Check if we should auto-trigger continue() (only from Kundenkarte) - const context = await this._navigationState.restoreContext<{ - returnUrl?: string; - autoTriggerContinueFn?: boolean; - }>('select-customer'); + this.checkHasReturnUrl(); - if (context?.autoTriggerContinueFn) { - // Clear the autoTriggerContinueFn flag immediately (preserves returnUrl automatically) - await this._navigationState.patchContext( - { autoTriggerContinueFn: undefined }, - 'select-customer', - ); + // Check if we should auto-trigger continue() (only from Kundenkarte) + const tab = this._tabService.activatedTab(); + const context = tab?.metadata?.['select-customer'] as + | SelectCustomerContext + | undefined; - // Auto-trigger continue() ONLY when coming from Kundenkarte - this.continue(); - } - }); + if (context?.autoTriggerContinueFn && tab) { + // Clear the autoTriggerContinueFn flag immediately (preserves returnUrl) + this._tabService.patchTabMetadata(tab.id, { + 'select-customer': { ...context, autoTriggerContinueFn: undefined }, + }); + + // Auto-trigger continue() ONLY when coming from Kundenkarte + this.continue(); + } this.processId$ .pipe( @@ -436,10 +441,18 @@ export class CustomerDetailsViewMainComponent // #5262 Check for reward selection flow before navigation if (this.hasReturnUrl()) { - // Restore from preserved context (auto-scoped to current tab) and clean up - const context = await this._navigationState.restoreAndClearContext<{ - returnUrl?: string; - }>('select-customer'); + // Restore from preserved context (scoped to current tab) and clean up + const tab = this._tabService.activatedTab(); + const context = tab?.metadata?.['select-customer'] as + | SelectCustomerContext + | undefined; + + // Clear the context + if (tab) { + this._tabService.patchTabMetadata(tab.id, { + 'select-customer': null, + }); + } if (context?.returnUrl) { await this._router.navigateByUrl(context.returnUrl); diff --git a/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.ts b/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.ts index e30ae9ce5..d41472a05 100644 --- a/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.ts +++ b/apps/isa-app/src/page/customer/customer-search/kundenkarte-main-view/kundenkarte-main-view.component.ts @@ -10,7 +10,7 @@ import { import { CustomerSearchStore } from '../store'; import { ActivatedRoute, Router } from '@angular/router'; import { map } from 'rxjs/operators'; -import { NavigationStateService } from '@isa/core/navigation'; +import { TabService } from '@isa/core/tabs'; import { CustomerSearchNavigation } from '@shared/services/navigation'; import { AsyncPipe } from '@angular/common'; import { CustomerMenuComponent } from '../../components/customer-menu'; @@ -51,7 +51,7 @@ export class KundenkarteMainViewComponent implements OnDestroy { #cardTransactionsResource = inject(CustomerCardTransactionsResource); elementRef = inject(ElementRef); #router = inject(Router); - #navigationState = inject(NavigationStateService); + #tabService = inject(TabService); #customerNavigationService = inject(CustomerSearchNavigation); /** @@ -120,13 +120,12 @@ export class KundenkarteMainViewComponent implements OnDestroy { } // Preserve context for auto-triggering continue() in details view - this.#navigationState.preserveContext( - { + this.#tabService.patchTabMetadata(tabId, { + 'select-customer': { returnUrl: `/${tabId}/reward`, autoTriggerContinueFn: true, }, - 'select-customer', - ); + }); // Navigate to customer details - will auto-trigger continue() await this.#router.navigate( diff --git a/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.html b/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.html index e8ef82bbd..d0cb96671 100644 --- a/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.html +++ b/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.html @@ -1,16 +1,43 @@ @if (modalRef.data.subtitle; as subtitle) {

{{ subtitle }}

} -@if (modalRef.data.content; as content) { -

- {{ content }} -

+ + +@if (shouldShowQrCode(); as showQr) { + @if (parsedContent(); as parsed) { + @if (parsed.textBefore) { +

{{ parsed.textBefore }}

+ } + +
+ +
+ + @if (parsed.textAfter) { +

{{ parsed.textAfter }}

+ } + } +} @else { + + @if (modalRef.data.content; as content) { +

+ {{ content }} +

+ } } @if (modalRef.data.actions; as actions) {
@for (action of actions; track action) { - } diff --git a/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.scss b/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.scss index f91cfdd15..727e485eb 100644 --- a/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.scss +++ b/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.scss @@ -15,11 +15,15 @@ @apply text-lg text-center whitespace-pre-wrap mb-8 px-16; } +.qr-code-container { + @apply flex flex-col items-center justify-center mb-8; +} + .actions { @apply text-center mb-8; button { - @apply border-2 border-solid border-brand bg-white text-brand rounded-full py-3 px-6 font-bold text-lg outline-none self-end whitespace-nowrap ml-4; + @apply border-2 border-solid border-brand bg-white text-brand rounded-full py-3 px-6 font-bold text-lg outline-none self-end whitespace-nowrap; &.selected { @apply bg-brand text-white; diff --git a/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.ts b/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.ts index 431753079..96fb03cd8 100644 --- a/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.ts +++ b/apps/isa-app/src/ui/modal/dialog/dialog-modal.component.ts @@ -1,7 +1,8 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, computed, OnInit } from '@angular/core'; import { CommandService } from '@core/command'; import { UiModalRef } from '../defs/modal-ref'; import { DialogModel } from './dialog.model'; +import { parseDialogContentForUrl } from './dialog.helper'; @Component({ selector: 'ui-dialog-modal', @@ -10,6 +11,26 @@ import { DialogModel } from './dialog.model'; standalone: false, }) export class UiDialogModalComponent implements OnInit { + /** + * Parsed content with URL extracted for QR code display. + * Only relevant when showUrlAsQrCode is true. + */ + readonly parsedContent = computed(() => { + const data = this.modalRef.data; + if (!data.showUrlAsQrCode) { + return null; + } + return parseDialogContentForUrl(data.content); + }); + + /** + * Whether to show the QR code instead of the URL text. + */ + readonly shouldShowQrCode = computed(() => { + const parsed = this.parsedContent(); + return parsed !== null && parsed.url !== null; + }); + constructor( public modalRef: UiModalRef>, private _command: CommandService, diff --git a/apps/isa-app/src/ui/modal/dialog/dialog.helper.ts b/apps/isa-app/src/ui/modal/dialog/dialog.helper.ts new file mode 100644 index 000000000..b4ba98f3f --- /dev/null +++ b/apps/isa-app/src/ui/modal/dialog/dialog.helper.ts @@ -0,0 +1,48 @@ +import { ParsedDialogContent } from './dialog.model'; + +/** + * Regular expression to match URLs in text. + * Matches http:// and https:// URLs. + */ +const URL_REGEX = /https?:\/\/[^\s]+/i; + +/** + * Parses the dialog content and extracts the first URL. + * Splits the content into text before the URL, the URL itself, and text after. + * + * @param content - The dialog content string to parse + * @returns ParsedDialogContent with the split content + */ +export const parseDialogContentForUrl = ( + content: string | undefined, +): ParsedDialogContent => { + if (!content) { + return { textBefore: '', url: null, textAfter: '' }; + } + + const match = content.match(URL_REGEX); + + if (!match || match.index === undefined) { + return { textBefore: content, url: null, textAfter: '' }; + } + + const url = match[0]; + const urlIndex = match.index; + const textBefore = content.substring(0, urlIndex).trim(); + const textAfter = content.substring(urlIndex + url.length).trim(); + + return { textBefore, url, textAfter }; +}; + +/** + * Checks if the given content contains a URL. + * + * @param content - The content string to check + * @returns true if a URL is found, false otherwise + */ +export const contentHasUrl = (content: string | undefined): boolean => { + if (!content) { + return false; + } + return URL_REGEX.test(content); +}; diff --git a/apps/isa-app/src/ui/modal/dialog/dialog.model.spec.ts b/apps/isa-app/src/ui/modal/dialog/dialog.model.spec.ts new file mode 100644 index 000000000..a7317312e --- /dev/null +++ b/apps/isa-app/src/ui/modal/dialog/dialog.model.spec.ts @@ -0,0 +1,152 @@ +import { contentHasUrl, parseDialogContentForUrl } from './dialog.helper'; +import { ParsedDialogContent } from './dialog.model'; + +describe('parseDialogContentForUrl', () => { + it('should return empty result for undefined content', () => { + const result = parseDialogContentForUrl(undefined); + + expect(result).toEqual({ + textBefore: '', + url: null, + textAfter: '', + }); + }); + + it('should return empty result for empty string', () => { + const result = parseDialogContentForUrl(''); + + expect(result).toEqual({ + textBefore: '', + url: null, + textAfter: '', + }); + }); + + it('should return content as textBefore when no URL is found', () => { + const content = 'This is some text without a URL'; + + const result = parseDialogContentForUrl(content); + + expect(result).toEqual({ + textBefore: content, + url: null, + textAfter: '', + }); + }); + + it('should extract https URL from content', () => { + const content = 'Text before https://example.com text after'; + + const result = parseDialogContentForUrl(content); + + expect(result).toEqual({ + textBefore: 'Text before', + url: 'https://example.com', + textAfter: 'text after', + }); + }); + + it('should extract http URL from content', () => { + const content = 'Text before http://example.com text after'; + + const result = parseDialogContentForUrl(content); + + expect(result).toEqual({ + textBefore: 'Text before', + url: 'http://example.com', + textAfter: 'text after', + }); + }); + + it('should handle URL at the beginning of content', () => { + const content = 'https://example.com/path text after'; + + const result = parseDialogContentForUrl(content); + + expect(result).toEqual({ + textBefore: '', + url: 'https://example.com/path', + textAfter: 'text after', + }); + }); + + it('should handle URL at the end of content', () => { + const content = 'Text before https://example.com/path'; + + const result = parseDialogContentForUrl(content); + + expect(result).toEqual({ + textBefore: 'Text before', + url: 'https://example.com/path', + textAfter: '', + }); + }); + + it('should handle real-world content with newlines', () => { + const content = `Punkte: 80500 +Um alle Vorteile der Kundenkarte nutzen zu können, ist eine Verknüpfung zu einem Online-Konto notwendig. Kund:innen können sich über den QR-Code selbstständig anmelden oder die Kundenkarte dem bestehendem Konto hinzufügen. Bereits gesammelte Punkte werden übernommen. +https://h-k.me/QOHNTFVA`; + + const result = parseDialogContentForUrl(content); + + expect(result.url).toBe('https://h-k.me/QOHNTFVA'); + expect(result.textBefore).toContain('Punkte: 80500'); + expect(result.textBefore).toContain( + 'Bereits gesammelte Punkte werden übernommen.', + ); + expect(result.textAfter).toBe(''); + }); + + it('should extract only the first URL when multiple URLs are present', () => { + const content = 'First https://first.com then https://second.com'; + + const result = parseDialogContentForUrl(content); + + expect(result).toEqual({ + textBefore: 'First', + url: 'https://first.com', + textAfter: 'then https://second.com', + }); + }); + + it('should handle URLs with paths and query parameters', () => { + const content = + 'Visit https://example.com/path?query=value&foo=bar for more'; + + const result = parseDialogContentForUrl(content); + + expect(result.url).toBe('https://example.com/path?query=value&foo=bar'); + expect(result.textBefore).toBe('Visit'); + expect(result.textAfter).toBe('for more'); + }); +}); + +describe('contentHasUrl', () => { + it('should return false for undefined content', () => { + expect(contentHasUrl(undefined)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(contentHasUrl('')).toBe(false); + }); + + it('should return false for content without URL', () => { + expect(contentHasUrl('This is text without a URL')).toBe(false); + }); + + it('should return true for content with https URL', () => { + expect(contentHasUrl('Check out https://example.com')).toBe(true); + }); + + it('should return true for content with http URL', () => { + expect(contentHasUrl('Check out http://example.com')).toBe(true); + }); + + it('should return true for real-world content', () => { + const content = `Punkte: 80500 +Um alle Vorteile der Kundenkarte nutzen zu können... +https://h-k.me/QOHNTFVA`; + + expect(contentHasUrl(content)).toBe(true); + }); +}); diff --git a/apps/isa-app/src/ui/modal/dialog/dialog.model.ts b/apps/isa-app/src/ui/modal/dialog/dialog.model.ts index 77005d38a..3a416abd2 100644 --- a/apps/isa-app/src/ui/modal/dialog/dialog.model.ts +++ b/apps/isa-app/src/ui/modal/dialog/dialog.model.ts @@ -1,4 +1,7 @@ -import { DialogSettings, KeyValueDTOOfStringAndString } from '@generated/swagger/crm-api'; +import { + DialogSettings, + KeyValueDTOOfStringAndString, +} from '@generated/swagger/crm-api'; export interface DialogModel { actions?: Array; @@ -14,4 +17,21 @@ export interface DialogModel { * default: true */ handleCommand?: boolean; + /** + * If true, URLs in the content will be displayed as QR codes. + * default: false + */ + showUrlAsQrCode?: boolean; +} + +/** + * Result of parsing content for URLs + */ +export interface ParsedDialogContent { + /** Text before the URL */ + textBefore: string; + /** The extracted URL (if any) */ + url: string | null; + /** Text after the URL */ + textAfter: string; } diff --git a/apps/isa-app/src/ui/modal/dialog/open-dialog.interceptor.ts b/apps/isa-app/src/ui/modal/dialog/open-dialog.interceptor.ts index 5ef09c091..2da2d7670 100644 --- a/apps/isa-app/src/ui/modal/dialog/open-dialog.interceptor.ts +++ b/apps/isa-app/src/ui/modal/dialog/open-dialog.interceptor.ts @@ -13,6 +13,7 @@ import { Observable, throwError } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; import { DialogModel } from './dialog.model'; import { ToasterService } from '@shared/shell'; +import { contentHasUrl } from './dialog.helper'; @Injectable() export class OpenDialogInterceptor implements HttpInterceptor { @@ -21,7 +22,10 @@ export class OpenDialogInterceptor implements HttpInterceptor { private _toast: ToasterService, ) {} - intercept(req: HttpRequest, next: HttpHandler): Observable> { + intercept( + req: HttpRequest, + next: HttpHandler, + ): Observable> { return next.handle(req).pipe( tap((response) => { if (response instanceof HttpResponse) { @@ -59,9 +63,17 @@ export class OpenDialogInterceptor implements HttpInterceptor { } openDialog(model: DialogModel) { + // Auto-detect URLs and enable QR code display if URL is found + // Can be overridden by explicitly setting showUrlAsQrCode in the model + const showUrlAsQrCode = + model.showUrlAsQrCode ?? contentHasUrl(model.content); + this._modal.open({ content: UiDialogModalComponent, - data: model, + data: { + ...model, + showUrlAsQrCode, + }, title: model.title, config: { canClose: (model.settings & 1) === 1, diff --git a/apps/isa-app/src/ui/modal/modal.module.ts b/apps/isa-app/src/ui/modal/modal.module.ts index 03061e45c..cdc69f0fa 100644 --- a/apps/isa-app/src/ui/modal/modal.module.ts +++ b/apps/isa-app/src/ui/modal/modal.module.ts @@ -2,7 +2,6 @@ import { OverlayModule } from '@angular/cdk/overlay'; import { CommonModule } from '@angular/common'; import { ModuleWithProviders, NgModule } from '@angular/core'; import { UiModalComponent } from './modal.component'; -import { UiModalService } from './modal.service'; import { UiDebugModalComponent } from './debug-modal/debug-modal.component'; import { UiMessageModalComponent } from './message-modal.component'; import { UiIconModule } from '@ui/icon'; @@ -10,9 +9,10 @@ import { UiDialogModalComponent } from './dialog/dialog-modal.component'; import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { OpenDialogInterceptor } from './dialog/open-dialog.interceptor'; import { UiPromptModalComponent } from './prompt-modal'; +import { QRCodeComponent } from 'angularx-qrcode'; @NgModule({ - imports: [CommonModule, OverlayModule, UiIconModule], + imports: [CommonModule, OverlayModule, UiIconModule, QRCodeComponent], declarations: [ UiModalComponent, UiDebugModalComponent, diff --git a/libs/checkout/feature/reward-catalog/src/lib/reward-action/reward-action.component.ts b/libs/checkout/feature/reward-catalog/src/lib/reward-action/reward-action.component.ts index c52c32731..86eaf8472 100644 --- a/libs/checkout/feature/reward-catalog/src/lib/reward-action/reward-action.component.ts +++ b/libs/checkout/feature/reward-catalog/src/lib/reward-action/reward-action.component.ts @@ -11,14 +11,13 @@ import { ShoppingCartFacade, SelectedRewardShoppingCartResource, } from '@isa/checkout/data-access'; -import { injectTabId } from '@isa/core/tabs'; +import { injectTabId, TabService } from '@isa/core/tabs'; import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons'; import { PurchaseOptionsModalService } from '@modal/purchase-options'; import { firstValueFrom } from 'rxjs'; import { Router } from '@angular/router'; import { getRouteToCustomer } from '../helpers'; import { PrimaryCustomerCardResource } from '@isa/crm/data-access'; -import { NavigationStateService } from '@isa/core/navigation'; @Component({ selector: 'reward-action', @@ -32,7 +31,7 @@ export class RewardActionComponent { #store = inject(RewardCatalogStore); #tabId = injectTabId(); - #navigationState = inject(NavigationStateService); + #tabService = inject(TabService); #purchasingOptionsModal = inject(PurchaseOptionsModalService); #shoppingCartFacade = inject(ShoppingCartFacade); #checkoutMetadataService = inject(CheckoutMetadataService); @@ -122,12 +121,9 @@ export class RewardActionComponent { const route = getRouteToCustomer(tabId); // Preserve context: Store current reward page URL to return to after customer selection - await this.#navigationState.preserveContext( - { - returnUrl: this.#router.url, - }, - 'select-customer', - ); + this.#tabService.patchTabMetadata(tabId, { + 'select-customer': { returnUrl: this.#router.url }, + }); await this.#router.navigate(route.path, { queryParams: route.queryParams, diff --git a/libs/checkout/feature/reward-catalog/src/lib/reward-header/reward-start-card/reward-start-card.component.ts b/libs/checkout/feature/reward-catalog/src/lib/reward-header/reward-start-card/reward-start-card.component.ts index 1f030e11d..7ded89105 100644 --- a/libs/checkout/feature/reward-catalog/src/lib/reward-header/reward-start-card/reward-start-card.component.ts +++ b/libs/checkout/feature/reward-catalog/src/lib/reward-header/reward-start-card/reward-start-card.component.ts @@ -1,9 +1,8 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ButtonComponent } from '@isa/ui/buttons'; -import { injectTabId } from '@isa/core/tabs'; +import { injectTabId, TabService } from '@isa/core/tabs'; import { Router } from '@angular/router'; import { getRouteToCustomer } from '../../helpers'; -import { NavigationStateService } from '@isa/core/navigation'; @Component({ selector: 'reward-start-card', @@ -13,7 +12,7 @@ import { NavigationStateService } from '@isa/core/navigation'; imports: [ButtonComponent], }) export class RewardStartCardComponent { - readonly #navigationState = inject(NavigationStateService); + readonly #tabService = inject(TabService); readonly #router = inject(Router); tabId = injectTabId(); @@ -22,19 +21,19 @@ export class RewardStartCardComponent { * Called when "Kund*in auswählen" button is clicked. * Preserves the current URL as returnUrl before navigating to customer search. */ - async onSelectCustomer() { + onSelectCustomer() { const customerRoute = getRouteToCustomer(this.tabId()); + const tabId = this.#tabService.activatedTabId(); // Preserve context: Store current reward page URL to return to after customer selection - await this.#navigationState.preserveContext( - { - returnUrl: this.#router.url, - }, - 'select-customer', - ); + if (tabId) { + this.#tabService.patchTabMetadata(tabId, { + 'select-customer': { returnUrl: this.#router.url }, + }); + } // Navigate to customer search - await this.#router.navigate(customerRoute.path, { + this.#router.navigate(customerRoute.path, { queryParams: customerRoute.queryParams, }); } 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 ddac3ac70..43126a2d4 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 @@ -14,8 +14,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'; +import { injectTabId, TabService } from '@isa/core/tabs'; +import { Router } from '@angular/router'; @Component({ selector: 'checkout-billing-and-shipping-address-card', @@ -26,7 +26,8 @@ import { NavigationStateService } from '@isa/core/navigation'; providers: [provideIcons({ isaActionEdit })], }) export class BillingAndShippingAddressCardComponent { - #navigationState = inject(NavigationStateService); + #tabService = inject(TabService); + #router = inject(Router); #shippingAddressResource = inject(SelectedCustomerShippingAddressResource); #payerAddressResource = inject(SelectedCustomerPayerAddressResource); @@ -45,18 +46,19 @@ export class BillingAndShippingAddressCardComponent { return this.#customerResource.value(); }); - async navigateToCustomer() { + navigateToCustomer() { const customerId = this.customer()?.id; - if (!customerId) return; + const tabId = this.tabId(); + if (!customerId || !tabId) return; - const returnUrl = `/${this.tabId()}/reward/cart`; + const returnUrl = `/${tabId}/reward/cart`; - // Preserve context across intermediate navigations (auto-scoped to active tab) - await this.#navigationState.navigateWithPreservedContext( - ['/', 'kunde', this.tabId(), 'customer', 'search', customerId], - { returnUrl }, - 'select-customer', - ); + // Preserve context across intermediate navigations (scoped to active tab) + this.#tabService.patchTabMetadata(tabId, { + 'select-customer': { returnUrl }, + }); + + this.#router.navigate(['/', 'kunde', tabId, 'customer', 'search', customerId]); } payer = computed(() => { diff --git a/libs/core/navigation/README.md b/libs/core/navigation/README.md deleted file mode 100644 index c85cbedac..000000000 --- a/libs/core/navigation/README.md +++ /dev/null @@ -1,855 +0,0 @@ -# @isa/core/navigation - -A reusable Angular library providing **context preservation** for multi-step navigation flows with automatic tab-scoped storage. - -## Overview - -`@isa/core/navigation` solves the problem of **lost navigation state** during intermediate navigations. Unlike Angular's router state which is lost after intermediate navigations, this library persists navigation context in **tab metadata** with automatic cleanup when tabs close. - -### The Problem It Solves - -```typescript -// ❌ Problem: Router state is lost during intermediate navigations -await router.navigate(['/customer/search'], { - state: { returnUrl: '/reward/cart' } // Works for immediate navigation -}); - -// After intermediate navigations: -// /customer/search → /customer/details → /add-shipping-address -// ⚠️ The returnUrl is LOST! -``` - -### The Solution - -```typescript -// ✅ Solution: Context preservation survives intermediate navigations -navState.preserveContext({ returnUrl: '/reward/cart' }); -// Context persists in tab metadata, automatically cleaned up when tab closes - -// After multiple intermediate navigations: -const context = navState.restoreAndClearContext(); -// ✅ returnUrl is PRESERVED! -``` - -## Features - -- ✅ **Survives Intermediate Navigations** - State persists across multiple navigation steps -- ✅ **Automatic Tab Scoping** - Contexts automatically isolated per tab using `TabService` -- ✅ **Automatic Cleanup** - Contexts cleared automatically when tabs close (no manual cleanup needed) -- ✅ **Hierarchical Scoping** - Combine tab ID with custom scopes (e.g., `"customer-details"`) -- ✅ **Type-Safe** - Full TypeScript generics support -- ✅ **Simple API** - No context IDs to track, scope is the identifier -- ✅ **Auto-Restore on Refresh** - Contexts survive page refresh (via TabService UserStorage persistence) -- ✅ **Map-Based Storage** - One context per scope for clarity -- ✅ **Platform-Agnostic** - Works with Angular Universal (SSR) -- ✅ **Zero URL Pollution** - No query parameters needed - -## Installation - -This library is part of the ISA Frontend monorepo. Import from the path alias: - -```typescript -import { NavigationStateService } from '@isa/core/navigation'; -``` - -## Quick Start - -### Basic Flow - -```typescript -import { Component, inject } from '@angular/core'; -import { Router } from '@angular/router'; -import { NavigationStateService } from '@isa/core/navigation'; - -@Component({ - selector: 'app-cart', - template: `` -}) -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 - ---- - -#### `patchContext(partialState, customScope?)` - -Partially update preserved context without replacing the entire context. - -This method merges partial state with the existing context, preserving properties you don't specify. Properties explicitly set to `undefined` will be removed from the context. - -```typescript -// Existing context: { returnUrl: '/cart', autoTriggerContinueFn: true, customerId: 123 } - -// Clear one property while preserving others -await navState.patchContext( - { autoTriggerContinueFn: undefined }, - 'select-customer' -); -// Result: { returnUrl: '/cart', customerId: 123 } - -// Update one property while preserving others -await navState.patchContext( - { customerId: 456 }, - 'select-customer' -); -// Result: { returnUrl: '/cart', customerId: 456 } - -// Add new property to existing context -await navState.patchContext( - { selectedTab: 'details' }, - 'select-customer' -); -// Result: { returnUrl: '/cart', customerId: 456, selectedTab: 'details' } -``` - -**Parameters:** -- `partialState`: Partial state object to merge (set properties to `undefined` to remove them) -- `customScope` (optional): Custom scope within the tab - -**Use Cases:** -- Clear trigger flags while preserving flow data -- Update specific properties without affecting others -- Remove properties from context -- Add properties to existing context - -**Comparison with `preserveContext`:** -- `preserveContext`: Replaces entire context (overwrites all properties) -- `patchContext`: Merges with existing context (updates only specified properties) - -```typescript -// ❌ preserveContext - must manually preserve existing properties -const context = await navState.restoreContext(); -await navState.preserveContext({ - returnUrl: context?.returnUrl, // Must specify - customerId: context?.customerId, // Must specify - autoTriggerContinueFn: undefined, // Clear this -}); - -// ✅ patchContext - automatically preserves unspecified properties -await navState.patchContext({ - autoTriggerContinueFn: undefined, // Only specify what changes -}); -``` - ---- - -#### `restoreContext(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 | -| `patchContext(partialState, customScope?)` | partialState: Partial, customScope?: string | void | Merge partial updates | -| `restoreContext(customScope?)` | customScope?: string | T \| null | Get context (keep) | -| `restoreAndClearContext(customScope?)` | customScope?: string | T \| null | Get + remove | -| `clearPreservedContext(customScope?)` | customScope?: string | boolean | Remove context | -| `hasPreservedContext(customScope?)` | customScope?: string | boolean | Check exists | -| `navigateWithPreservedContext(...)` | commands, state, customScope?, extras? | Promise<{success}> | Navigate + preserve | -| `clearScopeContexts()` | none | number | Bulk cleanup (rarely needed) | - ---- - -## Troubleshooting - -### Context Not Found After Refresh - -**Problem**: Context is `null` after page refresh. - -**Solution**: Ensure `TabService` is properly initialized and the tab ID is restored from UserStorage. Contexts rely on tab metadata which persists via UserStorage. - -### Context Cleared Unexpectedly - -**Problem**: Context disappears before you retrieve it. - -**Solution**: Check if you're using `restoreAndClearContext()` multiple times. This method removes the context after retrieval. Use `restoreContext()` if you need to access it multiple times. - -### "No active tab" Error - -**Problem**: Getting error "No active tab - cannot set navigation context". - -**Solution**: Ensure `TabService` has an active tab before using navigation context. This typically happens during app initialization before tabs are ready. - -### Context Not Isolated Between Tabs - -**Problem**: Contexts from one tab appearing in another. - -**Solution**: This should not happen with tab metadata storage. If you see this, it may indicate a TabService issue. Check that `TabService.activatedTabId()` returns the correct tab ID. - ---- - -## Running Tests - -```bash -# Run tests -npx nx test core-navigation - -# Run tests with coverage -npx nx test core-navigation --coverage.enabled=true - -# Run tests without cache (CI) -npx nx test core-navigation --skip-cache -``` - -**Test Results:** -- 79 tests passing -- 2 test files (navigation-state.service.spec.ts, navigation-context.service.spec.ts) - ---- - -## CI/CD Integration - -This library generates JUnit and Cobertura reports for Azure Pipelines: - -- **JUnit Report**: `testresults/junit-core-navigation.xml` -- **Cobertura Report**: `coverage/libs/core/navigation/cobertura-coverage.xml` - ---- - -## Contributing - -This library follows the ISA Frontend monorepo conventions: - -- **Path Alias**: `@isa/core/navigation` -- **Testing Framework**: Vitest with Angular Testing Utilities -- **Code Style**: ESLint + Prettier -- **Test Coverage**: Required for all public APIs -- **Dependencies**: Requires `@isa/core/tabs` for tab scoping - ---- - -## License - -Internal ISA Frontend monorepo library. diff --git a/libs/core/navigation/eslint.config.cjs b/libs/core/navigation/eslint.config.cjs deleted file mode 100644 index bdab98018..000000000 --- a/libs/core/navigation/eslint.config.cjs +++ /dev/null @@ -1,34 +0,0 @@ -const nx = require('@nx/eslint-plugin'); -const baseConfig = require('../../../eslint.config.js'); - -module.exports = [ - ...baseConfig, - ...nx.configs['flat/angular'], - ...nx.configs['flat/angular-template'], - { - files: ['**/*.ts'], - rules: { - '@angular-eslint/directive-selector': [ - 'error', - { - type: 'attribute', - prefix: 'core', - style: 'camelCase', - }, - ], - '@angular-eslint/component-selector': [ - 'error', - { - type: 'element', - prefix: 'core', - style: 'kebab-case', - }, - ], - }, - }, - { - files: ['**/*.html'], - // Override or add rules here - rules: {}, - }, -]; diff --git a/libs/core/navigation/project.json b/libs/core/navigation/project.json deleted file mode 100644 index 61c69246f..000000000 --- a/libs/core/navigation/project.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "core-navigation", - "$schema": "../../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "libs/core/navigation/src", - "prefix": "core", - "projectType": "library", - "tags": ["skip:ci"], - "targets": { - "test": { - "executor": "@nx/vite:test", - "outputs": ["{options.reportsDirectory}"], - "options": { - "reportsDirectory": "../../../coverage/libs/core/navigation" - } - }, - "lint": { - "executor": "@nx/eslint:lint" - } - } -} diff --git a/libs/core/navigation/src/index.ts b/libs/core/navigation/src/index.ts deleted file mode 100644 index 6a10b041d..000000000 --- a/libs/core/navigation/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './lib/navigation-state.types'; -export * from './lib/navigation-state.service'; -export * from './lib/navigation-context.types'; -export * from './lib/navigation-context.service'; -export * from './lib/navigation-context.constants'; diff --git a/libs/core/navigation/src/lib/navigation-context.constants.ts b/libs/core/navigation/src/lib/navigation-context.constants.ts deleted file mode 100644 index b23d06293..000000000 --- a/libs/core/navigation/src/lib/navigation-context.constants.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Constants for navigation context storage in tab metadata. - * Navigation contexts are stored directly in tab metadata instead of sessionStorage, - * providing automatic cleanup when tabs are closed and better integration with the tab system. - */ - -/** - * Key used to store navigation contexts in tab metadata. - * Contexts are stored as: tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY][customScope] - * - * @example - * ```typescript - * // Structure in tab metadata: - * tab.metadata = { - * 'navigation-contexts': { - * 'default': { data: { returnUrl: '/cart' }, createdAt: 123 }, - * 'customer-details': { data: { customerId: 42 }, createdAt: 456 } - * } - * } - * ``` - */ -export const NAVIGATION_CONTEXT_METADATA_KEY = 'navigation-contexts'; diff --git a/libs/core/navigation/src/lib/navigation-context.service.spec.ts b/libs/core/navigation/src/lib/navigation-context.service.spec.ts deleted file mode 100644 index c24dff77c..000000000 --- a/libs/core/navigation/src/lib/navigation-context.service.spec.ts +++ /dev/null @@ -1,668 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { signal } from '@angular/core'; -import { NavigationContextService } from './navigation-context.service'; -import { TabService } from '@isa/core/tabs'; -import { ReturnUrlContext } from './navigation-context.types'; -import { NAVIGATION_CONTEXT_METADATA_KEY } from './navigation-context.constants'; - -describe('NavigationContextService', () => { - let service: NavigationContextService; - let tabServiceMock: { - activatedTabId: ReturnType>; - 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 deleted file mode 100644 index 170b58bf0..000000000 --- a/libs/core/navigation/src/lib/navigation-context.service.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { TabService } from '@isa/core/tabs'; -import { logger } from '@isa/core/logging'; -import { - NavigationContext, - NavigationContextData, - NavigationContextsMetadataSchema, -} from './navigation-context.types'; -import { NAVIGATION_CONTEXT_METADATA_KEY } from './navigation-context.constants'; - -/** - * Service for managing navigation context using tab metadata storage. - * - * This service provides a type-safe approach to preserving navigation state - * across intermediate navigations, solving the problem of lost router state - * in multi-step flows. - * - * Key Features: - * - Stores contexts in tab metadata (automatic cleanup when tab closes) - * - Type-safe with Zod validation - * - Scoped to individual tabs (no cross-tab pollution) - * - Simple API with hierarchical scoping support - * - * Storage Architecture: - * - Contexts stored at: `tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY]` - * - Structure: `{ [customScope]: { data, createdAt } }` - * - No manual cleanup needed (handled by tab lifecycle) - * - * @example - * ```typescript - * // Start of flow - preserve context (auto-scoped to active tab) - * contextService.setContext({ - * returnUrl: '/original-page', - * customerId: 123 - * }); - * - * // ... intermediate navigations happen ... - * - * // End of flow - restore and cleanup - * const context = contextService.getAndClearContext<{ returnUrl: string }>(); - * if (context?.returnUrl) { - * await router.navigateByUrl(context.returnUrl); - * } - * ``` - */ -@Injectable({ providedIn: 'root' }) -export class NavigationContextService { - readonly #tabService = inject(TabService); - readonly #log = logger(() => ({ module: 'navigation-context' })); - - /** - * Get the navigation contexts map from tab metadata. - * - * @param tabId The tab ID to get contexts for - * @returns Record of scope keys to contexts, or empty object if not found - */ - #getContextsMap(tabId: number): Record { - 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, - })); - } - - /** - * Patch a context in the active tab's metadata. - * - * Merges partial data with the existing context, preserving unspecified properties. - * Properties explicitly set to `undefined` will be removed from the context. - * If the context doesn't exist, creates a new one (behaves like setContext). - * - * @template T The type of data being patched - * @param partialData The partial navigation data to merge - * @param customScope Optional custom scope (defaults to 'default') - * - * @example - * ```typescript - * // Clear one property while preserving others - * contextService.patchContext({ autoTriggerContinueFn: undefined }, 'select-customer'); - * - * // Update one property while preserving others - * contextService.patchContext({ selectedTab: 'details' }, 'customer-flow'); - * ``` - */ - async patchContext( - partialData: Partial, - customScope?: string, - ): Promise { - const tabId = this.#tabService.activatedTabId(); - if (tabId === null) { - throw new Error('No active tab - cannot patch navigation context'); - } - - const scopeKey = customScope || 'default'; - const existingContext = await this.getContext(customScope); - - const mergedData = { - ...(existingContext ?? {}), - ...partialData, - }; - - // Remove properties explicitly set to undefined - const removedKeys: string[] = []; - Object.keys(mergedData).forEach((key) => { - if (mergedData[key] === undefined) { - removedKeys.push(key); - delete mergedData[key]; - } - }); - - const contextsMap = this.#getContextsMap(tabId); - const context: NavigationContext = { - data: mergedData, - createdAt: - existingContext && contextsMap[scopeKey] - ? contextsMap[scopeKey].createdAt - : Date.now(), - }; - - contextsMap[scopeKey] = context; - this.#saveContextsMap(tabId, contextsMap); - - this.#log.debug('Context patched in tab metadata', () => ({ - tabId, - scopeKey, - patchedKeys: Object.keys(partialData), - removedKeys, - totalDataKeys: Object.keys(mergedData), - wasUpdate: existingContext !== null, - totalContexts: Object.keys(contextsMap).length, - })); - } - - /** - * Get a context from the active tab's metadata without removing it. - * - * Retrieves a preserved navigation context by scope. - * - * @template T The expected type of the context data - * @param customScope Optional custom scope (defaults to 'default') - * @returns The context data, or null if not found - * - * @example - * ```typescript - * // Get context for default scope - * const context = contextService.getContext<{ returnUrl: string }>(); - * - * // Get context for custom scope - * const context = contextService.getContext<{ customerId: number }>('customer-details'); - * ``` - */ - async getContext( - 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 deleted file mode 100644 index bada5b424..000000000 --- a/libs/core/navigation/src/lib/navigation-context.types.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { z } from 'zod'; - -/** - * Base interface for navigation context data. - * Extend this interface for type-safe context preservation. - * - * @example - * ```typescript - * interface MyFlowContext extends NavigationContextData { - * returnUrl: string; - * selectedItems: number[]; - * } - * ``` - */ -export interface NavigationContextData { - [key: string]: unknown; -} - -/** - * Navigation context stored in tab metadata. - * Represents a single preserved navigation state with metadata. - * Stored at: tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY][customScope] - */ -export interface NavigationContext { - /** The preserved navigation state/data */ - data: NavigationContextData; - /** Timestamp when context was created (for debugging and monitoring) */ - createdAt: number; - /** - * Optional expiration timestamp (reserved for future TTL implementation) - * @deprecated Currently unused - contexts are cleaned up automatically when tabs close - */ - expiresAt?: number; -} - -/** - * Zod schema for navigation context data validation. - */ -export const NavigationContextDataSchema = z.record(z.string(), z.unknown()); - -/** - * Zod schema for navigation context validation. - */ -export const NavigationContextSchema = z.object({ - data: NavigationContextDataSchema, - createdAt: z.number().positive(), - expiresAt: z.number().positive().optional(), -}); - -/** - * Zod schema for navigation contexts stored in tab metadata. - * Structure: { [customScope: string]: NavigationContext } - * - * @example - * ```typescript - * { - * "default": { data: { returnUrl: '/cart' }, createdAt: 123, expiresAt: 456 }, - * "customer-details": { data: { customerId: 42 }, createdAt: 123 } - * } - * ``` - */ -export const NavigationContextsMetadataSchema = z.record( - z.string(), - NavigationContextSchema -); - -/** - * Common navigation context for "return URL" pattern. - * Used when navigating through a flow and needing to return to the original location. - * - * @example - * ```typescript - * navContextService.preserveContext({ - * returnUrl: '/original-page' - * }); - * ``` - */ -export interface ReturnUrlContext extends NavigationContextData { - returnUrl: string; -} - -/** - * Extended context with additional flow metadata. - * Useful for complex multi-step flows that need to preserve additional state. - * - * @example - * ```typescript - * interface CheckoutFlowContext extends FlowContext { - * returnUrl: string; - * selectedProductIds: number[]; - * shippingAddressId?: number; - * } - * ``` - */ -export interface FlowContext extends NavigationContextData { - /** Step identifier for multi-step flows */ - currentStep?: string; - /** Total number of steps (if known) */ - totalSteps?: number; - /** Flow-specific metadata */ - metadata?: Record; -} diff --git a/libs/core/navigation/src/lib/navigation-state.service.spec.ts b/libs/core/navigation/src/lib/navigation-state.service.spec.ts deleted file mode 100644 index db689b3e0..000000000 --- a/libs/core/navigation/src/lib/navigation-state.service.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { Location } from '@angular/common'; -import { Router } from '@angular/router'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { NavigationStateService } from './navigation-state.service'; -import { NavigationContextService } from './navigation-context.service'; -import { ReturnUrlContext } from './navigation-context.types'; - -describe('NavigationStateService', () => { - let service: NavigationStateService; - let locationMock: { getState: ReturnType }; - 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 deleted file mode 100644 index 4983e0127..000000000 --- a/libs/core/navigation/src/lib/navigation-state.service.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { Router, NavigationExtras } from '@angular/router'; -import { NavigationContextService } from './navigation-context.service'; -import { NavigationContextData } from './navigation-context.types'; - -/** - * Service for managing navigation context preservation across multi-step flows. - * - * This service provides automatic context preservation using tab metadata, - * allowing navigation state to survive intermediate navigations. Contexts are - * automatically scoped to the active tab and cleaned up when the tab closes. - * - * ## Context Preservation for Multi-Step Flows - * - * @example - * ```typescript - * // Start of flow - preserve context (automatically scoped to active tab) - * await navigationStateService.preserveContext({ - * returnUrl: '/reward/cart', - * customerId: 123 - * }); - * - * // ... multiple intermediate navigations happen ... - * await router.navigate(['/customer/details']); - * await router.navigate(['/add-shipping-address']); - * - * // End of flow - restore and cleanup (auto-scoped to active tab) - * const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>(); - * if (context?.returnUrl) { - * await router.navigateByUrl(context.returnUrl); - * } - * ``` - * - * ## Simplified Navigation with Context - * - * @example - * ```typescript - * // Navigate and preserve context in one call - * const { success } = await navigationStateService.navigateWithPreservedContext( - * ['/customer/search'], - * { returnUrl: '/reward/cart' } - * ); - * - * // Later, restore and navigate back - * const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>(); - * if (context?.returnUrl) { - * await router.navigateByUrl(context.returnUrl); - * } - * ``` - * - * ## Automatic Tab Cleanup - * - * @example - * ```typescript - * ngOnDestroy() { - * // Clean up all contexts when tab closes (auto-uses active tab ID) - * this.navigationStateService.clearScopeContexts(); - * } - * ``` - * - * Key Features: - * - ✅ Automatic tab scoping using TabService - * - ✅ Stored in tab metadata (automatic cleanup when tab closes) - * - ✅ Type-safe with TypeScript generics and Zod validation - * - ✅ Automatic cleanup with restoreAndClearContext() - * - ✅ Support for multiple custom scopes per tab - * - ✅ No manual expiration management needed - * - ✅ Platform-agnostic (works with SSR) - */ -@Injectable({ providedIn: 'root' }) -export class NavigationStateService { - readonly #router = inject(Router); - readonly #contextService = inject(NavigationContextService); - - // Context Preservation Methods - - /** - * Preserve navigation state for multi-step flows. - * - * This method stores navigation context in tab metadata, allowing it to - * persist across intermediate navigations within a flow. Contexts are automatically - * scoped to the active tab, with optional custom scope for different flows. - * - * Use this when starting a flow that will have intermediate navigations - * before returning to the original location. - * - * @template T The type of state data being preserved - * @param state The navigation state to preserve - * @param customScope Optional custom scope within the tab (e.g., 'customer-details') - * - * @example - * ```typescript - * // Preserve context for default tab scope - * await navigationStateService.preserveContext({ returnUrl: '/products' }); - * - * // Preserve context for custom scope within tab - * await navigationStateService.preserveContext({ customerId: 42 }, 'customer-details'); - * - * // ... multiple intermediate navigations ... - * - * // Restore at end of flow - * const context = await navigationStateService.restoreContext<{ returnUrl: string }>(); - * if (context?.returnUrl) { - * await router.navigateByUrl(context.returnUrl); - * } - * ``` - */ - async preserveContext( - state: T, - customScope?: string, - ): Promise { - await this.#contextService.setContext(state, customScope); - } - - /** - * Patch preserved navigation state. - * - * Merges partial state with existing preserved context, keeping unspecified properties intact. - * This is useful when you need to update or clear specific properties without replacing - * the entire context. Properties set to `undefined` will be removed. - * - * Use cases: - * - Clear a trigger flag while preserving return URL - * - Update one property in a multi-property context - * - Remove specific properties from context - * - * @template T The type of state data being patched - * @param partialState The partial state to merge (properties set to undefined will be removed) - * @param customScope Optional custom scope within the tab - * - * @example - * ```typescript - * // Clear the autoTriggerContinueFn flag while preserving returnUrl - * await navigationStateService.patchContext( - * { autoTriggerContinueFn: undefined }, - * 'select-customer' - * ); - * - * // Update selectedTab while keeping other properties - * await navigationStateService.patchContext( - * { selectedTab: 'rewards' }, - * 'customer-flow' - * ); - * - * // Add a new property to existing context - * await navigationStateService.patchContext( - * { shippingAddressId: 123 }, - * 'checkout-flow' - * ); - * ``` - */ - async patchContext( - partialState: Partial, - customScope?: string, - ): Promise { - await this.#contextService.patchContext(partialState, customScope); - } - - /** - * Restore preserved navigation state. - * - * Retrieves a previously preserved navigation context for the active tab scope, - * or a custom scope if specified. - * - * This method does NOT remove the context - use clearPreservedContext() or - * restoreAndClearContext() for automatic cleanup. - * - * @template T The expected type of the preserved state - * @param customScope Optional custom scope (defaults to active tab scope) - * @returns The preserved state, or null if not found - * - * @example - * ```typescript - * // Restore from default tab scope - * const context = navigationStateService.restoreContext<{ returnUrl: string }>(); - * if (context?.returnUrl) { - * console.log('Returning to:', context.returnUrl); - * } - * - * // Restore from custom scope - * const context = navigationStateService.restoreContext<{ customerId: number }>('customer-details'); - * ``` - */ - async restoreContext( - 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 deleted file mode 100644 index 13dc096fd..000000000 --- a/libs/core/navigation/src/lib/navigation-state.types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Type definition for navigation state that can be passed through Angular Router. - * Use generic type parameter to ensure type safety for your specific state shape. - * - * @example - * ```typescript - * interface MyNavigationState extends NavigationState { - * returnUrl: string; - * customerId: number; - * } - * ``` - */ -export interface NavigationState { - [key: string]: unknown; -} - -/** - * Common navigation state for "return URL" pattern. - * Used when navigating to a page and needing to return to the previous location. - */ -export interface ReturnUrlNavigationState extends NavigationState { - returnUrl: string; -} diff --git a/libs/core/navigation/src/test-setup.ts b/libs/core/navigation/src/test-setup.ts deleted file mode 100644 index cebf5ae72..000000000 --- a/libs/core/navigation/src/test-setup.ts +++ /dev/null @@ -1,13 +0,0 @@ -import '@angular/compiler'; -import '@analogjs/vitest-angular/setup-zone'; - -import { - BrowserTestingModule, - platformBrowserTesting, -} from '@angular/platform-browser/testing'; -import { getTestBed } from '@angular/core/testing'; - -getTestBed().initTestEnvironment( - BrowserTestingModule, - platformBrowserTesting(), -); diff --git a/libs/core/navigation/tsconfig.json b/libs/core/navigation/tsconfig.json deleted file mode 100644 index 3268ed4dc..000000000 --- a/libs/core/navigation/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "importHelpers": true, - "moduleResolution": "bundler", - "strict": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "module": "preserve" - }, - "angularCompilerOptions": { - "enableI18nLegacyMessageIdFormat": false, - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "typeCheckHostBindings": true, - "strictTemplates": true - }, - "files": [], - "include": [], - "references": [ - { - "path": "./tsconfig.lib.json" - }, - { - "path": "./tsconfig.spec.json" - } - ] -} diff --git a/libs/core/navigation/tsconfig.lib.json b/libs/core/navigation/tsconfig.lib.json deleted file mode 100644 index 312ee86bb..000000000 --- a/libs/core/navigation/tsconfig.lib.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../../dist/out-tsc", - "declaration": true, - "declarationMap": true, - "inlineSources": true, - "types": [] - }, - "exclude": [ - "src/**/*.spec.ts", - "src/test-setup.ts", - "jest.config.ts", - "src/**/*.test.ts", - "vite.config.ts", - "vite.config.mts", - "vitest.config.ts", - "vitest.config.mts", - "src/**/*.test.tsx", - "src/**/*.spec.tsx", - "src/**/*.test.js", - "src/**/*.spec.js", - "src/**/*.test.jsx", - "src/**/*.spec.jsx" - ], - "include": ["src/**/*.ts"] -} diff --git a/libs/core/navigation/tsconfig.spec.json b/libs/core/navigation/tsconfig.spec.json deleted file mode 100644 index 5785a8a5f..000000000 --- a/libs/core/navigation/tsconfig.spec.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../../dist/out-tsc", - "types": [ - "vitest/globals", - "vitest/importMeta", - "vite/client", - "node", - "vitest" - ] - }, - "include": [ - "vite.config.ts", - "vite.config.mts", - "vitest.config.ts", - "vitest.config.mts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.test.tsx", - "src/**/*.spec.tsx", - "src/**/*.test.js", - "src/**/*.spec.js", - "src/**/*.test.jsx", - "src/**/*.spec.jsx", - "src/**/*.d.ts" - ], - "files": ["src/test-setup.ts"] -} diff --git a/libs/core/navigation/vite.config.mts b/libs/core/navigation/vite.config.mts deleted file mode 100644 index 18ed5d6b9..000000000 --- a/libs/core/navigation/vite.config.mts +++ /dev/null @@ -1,33 +0,0 @@ -/// -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/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html index c0898367a..d19717aad 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html @@ -5,8 +5,8 @@ @for (quantity of quantityDropdownValues(); track quantity) { {{ @@ -17,11 +17,11 @@ } @for (kv of availableCategories; track kv.key) { {{ kv.value }} @@ -31,7 +31,7 @@ @if (canReturnReceiptItem()) { @if (!canReturnResource.isLoading() && selectable()) { - + canReturnReceiptItem(this.item())); + dropdownLabel = computed(() => { + const category = this.productCategory(); + return !!category && category !== ProductCategory.Unknown + ? category + : 'Produktart'; + }); + setProductCategory(category: ProductCategory | undefined) { if (!category) { category = ProductCategory.Unknown; diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html index 0541d5c40..7250b07d6 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html @@ -57,21 +57,25 @@
- + @if (!!i?.product?.format) { + + } - {{ i.product.formatDetail }} + {{ i?.product?.formatDetail }}
- {{ i.product.manufacturer }} | {{ i.product.ean }} -
-
- {{ i.product.publicationDate | date: 'dd. MMM yyyy' }} + {{ i?.product?.manufacturer }} | {{ i?.product?.ean }}
+ @if (!!i?.product?.publicationDate) { +
+ {{ i.product.publicationDate | date: 'dd. MMM yyyy' }} +
+ }
diff --git a/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts b/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts index f324f3cde..49f2b6433 100644 --- a/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts +++ b/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts @@ -10,7 +10,6 @@ import { inject, input, model, - Signal, signal, viewChild, } from '@angular/core'; @@ -74,8 +73,8 @@ import { DROPDOWN_HOST, DropdownHost } from './dropdown-host'; '(keydown.escape)': 'close()', '(keydown.space)': 'onSpaceKey($event)', '(click)': - 'disabled() ? $event.stopImmediatePropagation() : (isOpen() ? close() : open())', - '(closeOnScroll)': 'close()', + 'disabled() ? $event.stopPropagation() : (isOpen() ? close() : open())', + '(closeOnScroll)': '(isOpen() ? close() : "")', }, }) export class DropdownButtonComponent diff --git a/libs/ui/layout/src/lib/close-on-scroll.directive.ts b/libs/ui/layout/src/lib/close-on-scroll.directive.ts index 9953ee32f..6bf5594fc 100644 --- a/libs/ui/layout/src/lib/close-on-scroll.directive.ts +++ b/libs/ui/layout/src/lib/close-on-scroll.directive.ts @@ -47,6 +47,7 @@ export class CloseOnScrollDirective implements OnDestroy { #document = inject(DOCUMENT); #scrollListener?: (event: Event) => void; #isActive = false; + #pendingActivation?: number; /** * When true, the directive listens for scroll events. @@ -81,50 +82,44 @@ export class CloseOnScrollDirective implements OnDestroy { if (this.#isActive) { return; } - - this.#scrollListener = (event: Event) => { - const target = event.target as HTMLElement; - const excludeElement = this.closeOnScrollExclude(); - - // Check if scroll happened within the excluded element - if (excludeElement?.contains(target)) { - return; - } - - // Emit close event - scroll happened outside excluded element - this.#logger.debug('Scroll detected outside panel, emitting close'); - this.closeOnScroll.emit(); - }; - - // Use capture: true to catch scroll events from ALL elements (scroll events don't bubble) - // Use passive: true for better performance (we don't call preventDefault) - this.#document.defaultView?.addEventListener( - 'scroll', - this.#scrollListener, - { - capture: true, - passive: true, - }, - ); - this.#isActive = true; - this.#logger.debug('Activated scroll listener'); + + // Delay listener registration to next frame to skip any stale scroll events + this.#pendingActivation = requestAnimationFrame(() => { + this.#scrollListener = (event: Event) => { + const excludeElement = this.closeOnScrollExclude(); + if (excludeElement?.contains(event.target as HTMLElement)) { + return; + } + this.#logger.debug('Scroll detected outside panel, emitting close'); + this.closeOnScroll.emit(); + }; + + this.#document.defaultView?.addEventListener( + 'scroll', + this.#scrollListener, + { capture: true, passive: true }, + ); + this.#logger.debug('Activated scroll listener'); + }); } #deactivate(): void { - if (!this.#isActive || !this.#scrollListener) { - return; + if (this.#pendingActivation) { + cancelAnimationFrame(this.#pendingActivation); + this.#pendingActivation = undefined; } - this.#document.defaultView?.removeEventListener( - 'scroll', - this.#scrollListener, - { capture: true }, - ); - - this.#scrollListener = undefined; - this.#isActive = false; + if (this.#scrollListener) { + this.#document.defaultView?.removeEventListener( + 'scroll', + this.#scrollListener, + { capture: true }, + ); + this.#scrollListener = undefined; + } this.#logger.debug('Deactivated scroll listener'); + this.#isActive = false; } ngOnDestroy(): void { diff --git a/package-lock.json b/package-lock.json index 5d5b04f15..60dba972b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@ngrx/store-devtools": "^20.0.0", "angular-oauth2-oidc": "^20.0.2", "angular-oauth2-oidc-jwks": "^20.0.0", + "angularx-qrcode": "^20.0.0", "date-fns": "^4.1.0", "jsbarcode": "^3.12.1", "lodash": "^4.17.21", @@ -15055,6 +15056,19 @@ "tslib": "^2.5.2" } }, + "node_modules/angularx-qrcode": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/angularx-qrcode/-/angularx-qrcode-20.0.0.tgz", + "integrity": "sha512-WZolRZztQsQxOXqodNSDicxPWNO79t/AT4wts+DxwYdtdXb1RELfZjtax9oGMQQ6mEZ6bwk5GqBGEDB3Y+cSqw==", + "license": "MIT", + "dependencies": { + "qrcode": "1.5.4", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": "^20.0.0" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -15098,7 +15112,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15108,7 +15121,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -15794,37 +15806,28 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" }, - "engines": { - "node": ">=0.10.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/bonjour-service": { @@ -16189,7 +16192,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -16630,7 +16632,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -16643,7 +16644,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colord": { @@ -18307,6 +18307,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -18595,6 +18604,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -29306,9 +29321,9 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.2.tgz", + "integrity": "sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw==", "dev": true, "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { @@ -30412,7 +30427,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -30673,7 +30687,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -30943,6 +30956,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -32100,6 +32122,156 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -32583,7 +32755,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -32599,6 +32770,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -33812,6 +33989,12 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -34599,7 +34782,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -37831,6 +38013,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -37876,7 +38064,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -37942,14 +38129,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -37959,7 +38144,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", diff --git a/package.json b/package.json index c59f5a2ba..1d08d4489 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@ngrx/store-devtools": "^20.0.0", "angular-oauth2-oidc": "^20.0.2", "angular-oauth2-oidc-jwks": "^20.0.0", + "angularx-qrcode": "^20.0.0", "date-fns": "^4.1.0", "jsbarcode": "^3.12.1", "lodash": "^4.17.21", diff --git a/tsconfig.base.json b/tsconfig.base.json index c7455f52c..3c6a0d231 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -72,7 +72,6 @@ "@isa/core/config": ["libs/core/config/src/index.ts"], "@isa/core/connectivity": ["libs/core/connectivity/src/index.ts"], "@isa/core/logging": ["libs/core/logging/src/index.ts"], - "@isa/core/navigation": ["libs/core/navigation/src/index.ts"], "@isa/core/storage": ["libs/core/storage/src/index.ts"], "@isa/core/tabs": ["libs/core/tabs/src/index.ts"], "@isa/crm/data-access": ["libs/crm/data-access/src/index.ts"],