From 7884e1af32c60c22e8cf8356f468efb42c23642d Mon Sep 17 00:00:00 2001 From: Nino Righi Date: Thu, 27 Nov 2025 16:41:51 +0000 Subject: [PATCH 1/6] Merged PR 2059: feature(ui-modal): add QR code display for URLs in dialog modals feature(ui-modal): add QR code display for URLs in dialog modals Add automatic URL detection and QR code rendering in dialog modals: - Parse dialog content to extract URLs (http/https) - Display extracted URLs as QR codes using angularx-qrcode library - Split content around URL to show text before and after the QR code - Auto-detect URLs by default, with optional showUrlAsQrCode override - Add comprehensive unit tests for URL parsing helpers Ref: #5511 --- .../modal/dialog/dialog-modal.component.html | 37 ++- .../modal/dialog/dialog-modal.component.scss | 6 +- .../ui/modal/dialog/dialog-modal.component.ts | 23 +- .../src/ui/modal/dialog/dialog.helper.ts | 48 ++++ .../src/ui/modal/dialog/dialog.model.spec.ts | 152 +++++++++++ .../src/ui/modal/dialog/dialog.model.ts | 22 +- .../modal/dialog/open-dialog.interceptor.ts | 16 +- apps/isa-app/src/ui/modal/modal.module.ts | 4 +- package-lock.json | 254 +++++++++++++++--- package.json | 1 + 10 files changed, 516 insertions(+), 47 deletions(-) create mode 100644 apps/isa-app/src/ui/modal/dialog/dialog.helper.ts create mode 100644 apps/isa-app/src/ui/modal/dialog/dialog.model.spec.ts 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/package-lock.json b/package-lock.json index e59fa4e67..08d35ff35 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", From ee9f030a99c283b82842f5ef5d1529ac47292bd5 Mon Sep 17 00:00:00 2001 From: Nino Righi Date: Mon, 1 Dec 2025 11:25:11 +0000 Subject: [PATCH 2/6] Merged PR 2062: fix(isa-app-customer): Clear Navigation State Context if Customer Area gets d... fix(isa-app-customer): Clear Navigation State Context if Customer Area gets destroyed Ref: #5512 --- .../src/page/customer/customer-page.component.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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..efb8c7453 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 { NavigationStateService } from '@isa/core/navigation'; import { map } from 'rxjs/operators'; @Component({ @@ -9,8 +15,14 @@ import { map } from 'rxjs/operators'; changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, }) -export class CustomerComponent { +export class CustomerComponent implements OnDestroy { + private _navigationState = inject(NavigationStateService); processId$ = this._activatedRoute.data.pipe(map((data) => data.processId)); constructor(private _activatedRoute: ActivatedRoute) {} + + async ngOnDestroy() { + // #5512 Always clear preserved select-customer context if navigating out of customer area + await this._navigationState.clearPreservedContext('select-customer'); + } } From aee63711e4eb8d25faa2b382c0565481f788e937 Mon Sep 17 00:00:00 2001 From: Nino Righi Date: Tue, 2 Dec 2025 14:02:32 +0000 Subject: [PATCH 3/6] Merged PR 2066: fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale sc... fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale scroll events from closing dropdown on open Delay scroll listener registration using requestAnimationFrame when activating CloseOnScrollDirective. This prevents stale scroll events still in the event queue from immediately triggering closeOnScroll when opening the dropdown after scrolling. Also adds conditional rendering for product format and publication date in return-details-order-group-item component. Refs: #5513 --- ...rn-details-order-group-item.component.html | 18 +++-- .../src/lib/dropdown/dropdown.component.ts | 4 +- .../src/lib/close-on-scroll.directive.ts | 69 +++++++++---------- 3 files changed, 45 insertions(+), 46 deletions(-) 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 85f01a2ab..aa0f845cd 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 @@ -44,21 +44,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 7cd60cb48..6698f0f0f 100644 --- a/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts +++ b/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts @@ -124,8 +124,8 @@ export class DropdownOptionComponent implements Highlightable { '(keydown.enter)': 'select(keyManger.activeItem); close()', '(keydown.escape)': 'close()', '(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 { From 5f945495391507d8734daf45b87c4a8b2a3ef157 Mon Sep 17 00:00:00 2001 From: Nino Date: Tue, 2 Dec 2025 16:32:57 +0100 Subject: [PATCH 4/6] fix(oms-return-details): Dropdown Label and Select Bullet Styling Adjustments Ref: #5513 --- ...s-order-group-item-controls.component.html | 12 ++++----- ...ils-order-group-item-controls.component.ts | 25 +++++++++++-------- 2 files changed, 21 insertions(+), 16 deletions(-) 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 ?? 'Produktart'; + }); + setProductCategory(category: ProductCategory | undefined) { if (!category) { category = ProductCategory.Unknown; From fdfb54a3a0f4622be0ca36440a60f2378e38f90e Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Tue, 2 Dec 2025 15:41:18 +0000 Subject: [PATCH 5/6] =?UTF-8?q?Merged=20PR=202065:=20=E2=99=BB=EF=B8=8F=20?= =?UTF-8?q?refactor(core-navigation):=20remove=20library=20and=20use=20Tab?= =?UTF-8?q?Service=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ♻️ refactor(core-navigation): remove library and use TabService directly Remove @isa/core/navigation library entirely as it was just a thin wrapper around TabService.patchTabMetadata(). Consumers now use TabService directly for scoped metadata operations. Changes: - Delete libs/core/navigation/ (~12 files, ~2900 LOC removed) - Update 6 consumer components to use TabService directly - Remove @isa/core/navigation path alias from tsconfig.base.json - All operations now synchronous (removed async/await) Migration pattern: - preserveContext() → patchTabMetadata(tabId, { [scope]: data }) - restoreContext() → activatedTab()?.metadata?.[scope] - restoreAndClearContext() → get + patchTabMetadata(tabId, { [scope]: null }) Refs #5502 --- .../kundenkarte/kundenkarte.component.ts | 12 +- .../details-main-view.component.ts | 73 +- .../kundenkarte-main-view.component.ts | 11 +- .../reward-action/reward-action.component.ts | 14 +- .../reward-start-card.component.ts | 21 +- ...ing-and-shipping-address-card.component.ts | 26 +- libs/core/navigation/README.md | 855 ------------------ libs/core/navigation/eslint.config.cjs | 34 - libs/core/navigation/project.json | 20 - libs/core/navigation/src/index.ts | 5 - .../src/lib/navigation-context.constants.ts | 22 - .../lib/navigation-context.service.spec.ts | 668 -------------- .../src/lib/navigation-context.service.ts | 442 --------- .../src/lib/navigation-context.types.ts | 102 --- .../src/lib/navigation-state.service.spec.ts | 227 ----- .../src/lib/navigation-state.service.ts | 331 ------- .../src/lib/navigation-state.types.ts | 23 - libs/core/navigation/src/test-setup.ts | 13 - libs/core/navigation/tsconfig.json | 30 - libs/core/navigation/tsconfig.lib.json | 27 - libs/core/navigation/tsconfig.spec.json | 29 - libs/core/navigation/vite.config.mts | 33 - tsconfig.base.json | 1 - 23 files changed, 82 insertions(+), 2937 deletions(-) delete mode 100644 libs/core/navigation/README.md delete mode 100644 libs/core/navigation/eslint.config.cjs delete mode 100644 libs/core/navigation/project.json delete mode 100644 libs/core/navigation/src/index.ts delete mode 100644 libs/core/navigation/src/lib/navigation-context.constants.ts delete mode 100644 libs/core/navigation/src/lib/navigation-context.service.spec.ts delete mode 100644 libs/core/navigation/src/lib/navigation-context.service.ts delete mode 100644 libs/core/navigation/src/lib/navigation-context.types.ts delete mode 100644 libs/core/navigation/src/lib/navigation-state.service.spec.ts delete mode 100644 libs/core/navigation/src/lib/navigation-state.service.ts delete mode 100644 libs/core/navigation/src/lib/navigation-state.types.ts delete mode 100644 libs/core/navigation/src/test-setup.ts delete mode 100644 libs/core/navigation/tsconfig.json delete mode 100644 libs/core/navigation/tsconfig.lib.json delete mode 100644 libs/core/navigation/tsconfig.spec.json delete mode 100644 libs/core/navigation/vite.config.mts 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-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/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/tsconfig.base.json b/tsconfig.base.json index 0f5b3d3db..6f2d370c3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -71,7 +71,6 @@ "@isa/core/auth": ["libs/core/auth/src/index.ts"], "@isa/core/config": ["libs/core/config/src/index.ts"], "@isa/core/logging": ["libs/core/logging/src/index.ts"], - "@isa/core/navigation": ["libs/core/navigation/src/index.ts"], "@isa/core/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"], From aa57d279243b3232f5ed6c9ddb28dbd778531dc0 Mon Sep 17 00:00:00 2001 From: Nino Date: Tue, 2 Dec 2025 17:20:56 +0100 Subject: [PATCH 6/6] fix(oms-return-details): Label Unknown Fix Ref: #5513 --- .../return-details-order-group-item-controls.component.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts index 3f4adeaee..2779ee5b1 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts @@ -91,7 +91,9 @@ export class ReturnDetailsOrderGroupItemControlsComponent { dropdownLabel = computed(() => { const category = this.productCategory(); - return category ?? 'Produktart'; + return !!category && category !== ProductCategory.Unknown + ? category + : 'Produktart'; }); setProductCategory(category: ProductCategory | undefined) {