From 7884e1af32c60c22e8cf8356f468efb42c23642d Mon Sep 17 00:00:00 2001 From: Nino Righi Date: Thu, 27 Nov 2025 16:41:51 +0000 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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;