Merged PR 2033: Add, Lock, Unlock Customer Cards

Add, Lock, Unlock Customer Cards

Refs: #5313, #5329, #5334, #5335
This commit is contained in:
Nino Righi
2025-11-20 13:59:26 +00:00
committed by Lorenz Hilpert
parent b7d008e339
commit 17cb0802c3
25 changed files with 908 additions and 191 deletions

View File

@@ -1,8 +1,9 @@
import { catchError } from 'rxjs/operators';
import { OperatorFunction, throwError } from 'rxjs';
import { catchError, mergeMap } from 'rxjs/operators';
import { OperatorFunction, throwError, pipe } from 'rxjs';
import { ResponseArgsError } from '../errors';
import { HttpErrorResponse } from '@angular/common/http';
import { ResponseArgs } from '../models';
import { isResponseArgs } from '../helpers';
/**
*
@@ -19,25 +20,34 @@ import { ResponseArgs } from '../models';
*
*/
export const catchResponseArgsErrorPipe = <T>(): OperatorFunction<T, T> =>
catchError((err: unknown) => {
if (err instanceof ResponseArgsError) {
return throwError(() => err);
}
if (err instanceof HttpErrorResponse && err.error) {
const payload = err.error as Partial<ResponseArgs<unknown>>;
if (payload.error === true) {
return throwError(
() =>
new ResponseArgsError({
error: true,
message: payload.message,
invalidProperties: payload.invalidProperties ?? {},
}),
);
pipe(
catchError((err: unknown) => {
if (err instanceof ResponseArgsError) {
return throwError(() => err);
}
}
return throwError(() => err);
});
if (err instanceof HttpErrorResponse && err.error) {
const payload = err.error as Partial<ResponseArgs<unknown>>;
if (payload.error === true) {
return throwError(
() =>
new ResponseArgsError({
error: true,
message: payload.message,
invalidProperties: payload.invalidProperties ?? {},
}),
);
}
}
return throwError(() => err);
}),
mergeMap((response) => {
if (isResponseArgs(response) && response.error === true) {
return throwError(() => new ResponseArgsError(response));
}
return [response];
}),
);

View File

@@ -1,6 +1,12 @@
import { inject, Injectable } from '@angular/core';
import { CrmSearchService } from '../services';
import { FetchCustomerCardsInput } from '../schemas';
import {
AddCardInput,
FetchCustomerCardsInput,
LockCardInput,
UnlockCardInput,
} from '../schemas';
import { AccountDetailsDTO } from '@generated/swagger/crm-api';
@Injectable({ providedIn: 'root' })
export class CustomerCardsFacade {
@@ -12,4 +18,16 @@ export class CustomerCardsFacade {
abortSignal,
);
}
async addCard(params: AddCardInput): Promise<AccountDetailsDTO | undefined> {
return this.#crmSearchService.addCard(params);
}
async lockCard(params: LockCardInput): Promise<boolean | undefined> {
return this.#crmSearchService.lockCard(params);
}
async unlockCard(params: UnlockCardInput): Promise<boolean | undefined> {
return this.#crmSearchService.unlockCard(params);
}
}

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const AddCardSchema = z.object({
customerId: z.number().describe('Unique customer identifier'),
loyaltyCardValues: z
.object({
cardCode: z.string().describe('Unique card code identifier'),
})
.describe('Loyalty card values'),
});
export type AddCard = z.infer<typeof AddCardSchema>;
export type AddCardInput = z.input<typeof AddCardSchema>;

View File

@@ -19,3 +19,6 @@ export * from './shipping-address.schema';
export * from './user.schema';
export * from './add-booking.schema';
export * from './bon-redemption.schema';
export * from './lock-card.schema';
export * from './unlock-card.schema';
export * from './add-card.schema';

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const LockCardSchema = z.object({
cardCode: z.string().describe('Unique card code identifier'),
});
export type LockCard = z.infer<typeof LockCardSchema>;
export type LockCardInput = z.input<typeof LockCardSchema>;

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const UnlockCardSchema = z.object({
cardCode: z.string().describe('Unique card code identifier'),
});
export type UnlockCard = z.infer<typeof UnlockCardSchema>;
export type UnlockCardInput = z.input<typeof UnlockCardSchema>;

View File

@@ -6,6 +6,7 @@ import {
KeyValueDTOOfStringAndString,
KeyValueDTOOfStringAndInteger,
LoyaltyBonResponse,
AccountDetailsDTO,
} from '@generated/swagger/crm-api';
import {
AddBookingInput,
@@ -19,6 +20,12 @@ import {
FetchCustomerCardsSchema,
FetchCustomerInput,
FetchCustomerSchema,
AddCardInput,
LockCardInput,
UnlockCardInput,
AddCardSchema,
LockCardSchema,
UnlockCardSchema,
} from '../schemas';
import {
catchResponseArgsErrorPipe,
@@ -140,6 +147,68 @@ export class CrmSearchService {
}
}
async addCard(params: AddCardInput): Promise<AccountDetailsDTO | undefined> {
this.#logger.info('Adding customer card');
const parsed = AddCardSchema.parse(params);
const req$ = this.#customerService
.CustomerAddLoyaltyCard({
customerId: parsed.customerId,
loyaltyCardValues: {
cardCode: parsed.loyaltyCardValues.cardCode,
},
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
return res?.result;
}
async lockCard(params: LockCardInput): Promise<boolean | undefined> {
this.#logger.info('Locking customer card');
const parsed = LockCardSchema.parse(params);
const req$ = this.#loyaltyCardService
.LoyaltyCardLockCard({
cardCode: parsed.cardCode,
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Lock card Failed', err);
throw err;
}
return res?.result;
}
async unlockCard(params: UnlockCardInput): Promise<boolean | undefined> {
this.#logger.info('Unlocking customer card');
const parsed = UnlockCardSchema.parse(params);
const req$ = this.#loyaltyCardService
.LoyaltyCardUnlockCard({
cardCode: parsed.cardCode,
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Unlock card Failed', err);
throw err;
}
return res?.result;
}
@Cache({ ttl: CacheTimeToLive.oneHour })
async fetchCurrentBookingPartnerStore(
abortSignal?: AbortSignal,
@@ -170,23 +239,17 @@ export class CrmSearchService {
): Promise<LoyaltyBookingInfoDTO | undefined> {
const parsed = AddBookingSchema.parse(params);
const req$ = this.#loyaltyCardService.LoyaltyCardAddBooking({
cardCode: parsed.cardCode,
booking: {
points: parsed.booking.points,
reason: parsed.booking.reason,
storeId: parsed.booking.storeId,
},
});
const req$ = this.#loyaltyCardService
.LoyaltyCardAddBooking({
cardCode: parsed.cardCode,
booking: {
points: parsed.booking.points,
reason: parsed.booking.reason,
storeId: parsed.booking.storeId,
},
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Add Booking Failed', err);
throw err;
}
return res?.result;
}
@@ -235,19 +298,14 @@ export class CrmSearchService {
this.#logger.info('Redeeming Bon from API');
const { cardCode, bonNr, storeId } = AddBonSchema.parse(params);
const req$ = this.#loyaltyCardService.LoyaltyCardLoyaltyBonAdd({
cardCode,
payload: { bonNr, storeId },
});
const req$ = this.#loyaltyCardService
.LoyaltyCardLoyaltyBonAdd({
cardCode,
payload: { bonNr, storeId },
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Bon redemption failed', err);
throw err;
}
this.#logger.debug('Successfully redeemed Bon');
return res?.result ?? false;
}

View File

@@ -66,6 +66,7 @@
class="w-40"
uiButton
type="button"
size="large"
color="primary"
(click)="booking()"
[disabled]="disableBooking()"

View File

@@ -5,6 +5,7 @@ import {
computed,
input,
inject,
OnDestroy,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@isa/ui/buttons';
@@ -38,7 +39,8 @@ import { TooltipIconComponent } from '@isa/ui/tooltip';
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [CustomerBookingReasonsResource],
})
export class CrmFeatureCustomerBookingComponent {
export class CrmFeatureCustomerBookingComponent implements OnDestroy {
#reloadTimeoutId?: ReturnType<typeof setTimeout>;
#logger = logger(() => ({
component: 'CrmFeatureCustomerBookingComponent',
}));
@@ -142,10 +144,14 @@ export class CrmFeatureCustomerBookingComponent {
}
reloadTransactionHistory() {
// Timeout to ensure that the new booking is available in the transaction history
setTimeout(() => {
this.#transactionResource.params({ cardCode: this.cardCode() });
this.#reloadTimeoutId = setTimeout(() => {
this.#transactionResource.resource.reload();
}, 500);
}
ngOnDestroy(): void {
if (this.#reloadTimeoutId) {
clearTimeout(this.#reloadTimeoutId);
}
}
}

View File

@@ -0,0 +1,11 @@
<button
uiTextButton
type="button"
data-what="button"
data-which="add-card"
aria-label="Kundenkarte hinzufügen"
[color]="'strong'"
(click)="addCardDialog()"
>
Karte hinzufügen
</button>

View File

@@ -0,0 +1,100 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
OnDestroy,
} from '@angular/core';
import { Validators } from '@angular/forms';
import { logger } from '@isa/core/logging';
import {
CustomerBonusCardsResource,
CustomerCardsFacade,
} from '@isa/crm/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { injectFeedbackDialog, injectTextInputDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'crm-add-customer-card',
templateUrl: './add-customer-card.component.html',
styleUrl: './add-customer-card.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TextButtonComponent],
})
export class AddCustomerCardComponent implements OnDestroy {
#reloadTimeoutId?: ReturnType<typeof setTimeout>;
#logger = logger(() => ({
component: 'AddCustomerCardComponent',
}));
#customerCardsFacade = inject(CustomerCardsFacade);
#bonusCardsResource = inject(CustomerBonusCardsResource);
#textDialog = injectTextInputDialog();
#feedbackDialog = injectFeedbackDialog();
readonly customerId = input.required<number>();
async addCardDialog() {
this.#logger.debug('Opening add card dialog');
const dialogRef = this.#textDialog({
title: 'Karte hinzufügen',
data: {
message:
'Scannen Sie die Karte, die Sie dem Kundenkonto hinzufügen möchten oder geben Sie den 8-stelligen Kartencode ein.',
inputLabel: 'Kartencode eingeben',
confirmText: 'Hinzufügen',
inputValidation: [
{
errorKey: 'required',
inputValidator: Validators.required,
errorText: 'Bitte geben Sie einen gültigen Kartencode ein',
},
],
onConfirm: async (cardCode: string) => {
await this.addCard(cardCode);
this.reloadCards();
},
},
width: '30rem',
});
const dialogResult = await firstValueFrom(dialogRef.closed);
if (dialogResult?.inputValue) {
this.feedback();
}
}
feedback() {
this.#feedbackDialog({
data: {
message: 'Karte wurde hinzugefügt',
},
});
}
async addCard(cardCode: string) {
this.#logger.info('Adding customer card', () => ({ cardCode }));
await this.#customerCardsFacade.addCard({
customerId: this.customerId(),
loyaltyCardValues: {
cardCode,
},
});
this.#logger.info('Adding customer card complete', () => ({ cardCode }));
}
reloadCards() {
this.#reloadTimeoutId = setTimeout(() => {
this.#bonusCardsResource.resource.reload();
}, 500);
}
ngOnDestroy(): void {
if (this.#reloadTimeoutId) {
clearTimeout(this.#reloadTimeoutId);
}
}
}

View File

@@ -1,6 +1,6 @@
<!-- Card container: 337×213px, rounded-2xl, shadow -->
<div
class="relative flex h-[13.3125rem] w-[21.0625rem] flex-col overflow-hidden rounded-2xl bg-isa-black shadow-card"
class="relative flex h-[14.8125rem] w-[21.0625rem] flex-col overflow-hidden rounded-2xl bg-isa-black shadow-card"
[attr.data-what]="'customer-card'"
[attr.data-which]="card().code"
>
@@ -39,19 +39,15 @@
<!-- White footer section: customer name -->
<div
class="min-h-[2.09rem] bg-isa-white isa-text-body-2-bold text-isa-black flex flex-col items-start justify-center"
class="min-h-[3.59rem] bg-isa-white isa-text-body-2-bold text-isa-black flex flex-col items-start justify-center"
>
<div class="isa-text-body-2-bold px-4">
{{ card().firstName }} {{ card().lastName }} Carsten Sievers
<div class="isa-text-body-2-bold px-3">
{{ card().firstName }} {{ card().lastName }}
</div>
</div>
<!-- Blocked overlay (if card is not active) -->
@if (!card().isActive) {
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-isa-black/90"
[attr.data-what]="'blocked-card-overlay'"
[attr.data-which]="card().code"
></div>
}
<crm-lock-customer-card
[isActive]="card().isActive"
[cardCode]="card().code"
></crm-lock-customer-card>
</div>
</div>

View File

@@ -1,5 +1,11 @@
import { Component, input, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { BonusCardInfo } from '@isa/crm/data-access';
import { LockCustomerCardComponent } from '../lock-customer-card/lock-customer-card.component';
/**
* Individual customer loyalty card display component.
@@ -24,6 +30,8 @@ import { BonusCardInfo } from '@isa/crm/data-access';
selector: 'crm-customer-card',
templateUrl: './customer-card.component.html',
styleUrl: './customer-card.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LockCustomerCardComponent],
})
export class CustomerCardComponent {
/**

View File

@@ -0,0 +1,29 @@
@if (isActive()) {
<button
uiTextButton
type="button"
size="small"
data-what="button"
data-which="lock-card"
[attr.aria-label]="'Kundenkarte ' + cardCode() + ' sperren'"
[attr.aria-busy]="loading()"
[color]="'strong'"
(click)="lockCard()"
>
Karte sperren
</button>
} @else {
<button
uiTextButton
type="button"
size="small"
data-what="button"
data-which="unlock-card"
[attr.aria-label]="'Kundenkarte ' + cardCode() + ' entsperren'"
[attr.aria-busy]="loading()"
[color]="'strong'"
(click)="unlockCard()"
>
Karte entsperren
</button>
}

View File

@@ -0,0 +1,109 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
OnDestroy,
signal,
} from '@angular/core';
import { logger } from '@isa/core/logging';
import {
CustomerBonusCardsResource,
CustomerCardsFacade,
} from '@isa/crm/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import {
injectFeedbackDialog,
injectFeedbackErrorDialog,
} from '@isa/ui/dialog';
@Component({
selector: 'crm-lock-customer-card',
templateUrl: './lock-customer-card.component.html',
styleUrl: './lock-customer-card.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TextButtonComponent],
})
export class LockCustomerCardComponent implements OnDestroy {
#reloadTimeoutId?: ReturnType<typeof setTimeout>;
#logger = logger(() => ({
component: 'LockCustomerCardComponent',
}));
#customerCardsFacade = inject(CustomerCardsFacade);
#bonusCardsResource = inject(CustomerBonusCardsResource);
#feedbackDialog = injectFeedbackDialog();
#errorFeedbackDialog = injectFeedbackErrorDialog();
readonly isActive = input.required<boolean>();
readonly cardCode = input.required<string>();
loading = signal(false);
async lockCard() {
const cardCode = this.cardCode();
try {
this.loading.set(true);
await this.#customerCardsFacade.lockCard({
cardCode,
});
this.#feedbackDialog({
data: {
message: 'Karte gesperrt',
},
});
this.reloadCards();
} catch (error: unknown) {
this.#logger.error('Error locking card', error as Error, () => ({
cardCode,
}));
this.#errorFeedbackDialog({
data: {
error,
},
});
} finally {
this.loading.set(false);
}
}
async unlockCard() {
const cardCode = this.cardCode();
try {
this.loading.set(true);
await this.#customerCardsFacade.unlockCard({
cardCode,
});
this.#feedbackDialog({
data: {
message: 'Karte entsperrt',
},
});
this.reloadCards();
} catch (error: unknown) {
this.#logger.error('Error unlocking card', error as Error, () => ({
cardCode,
}));
this.#errorFeedbackDialog({
data: {
error,
},
});
} finally {
this.loading.set(false);
}
}
reloadCards() {
this.#reloadTimeoutId = setTimeout(() => {
this.#bonusCardsResource.resource.reload();
}, 500);
}
ngOnDestroy(): void {
if (this.#reloadTimeoutId) {
clearTimeout(this.#reloadTimeoutId);
}
}
}

View File

@@ -30,25 +30,6 @@
<!-- Cards carousel -->
<crm-customer-cards-carousel [cards]="cardList" class="w-full" />
<!-- Action buttons (TODO: implement) -->
<div class="flex gap-4" data-what="card-actions">
<button
uiTextButton
type="button"
data-what="add-card-button"
[color]="'strong'"
>
Karte hinzufügen
</button>
<button
uiTextButton
type="button"
data-what="block-card-button"
[color]="'strong'"
>
Karte sperren
</button>
</div>
<crm-add-customer-card [customerId]="customerId()" />
}
</div>

View File

@@ -6,6 +6,7 @@ import { CustomerCardsCarouselComponent } from './components/customer-cards-caro
import { coerceNumberProperty, NumberInput } from '@angular/cdk/coercion';
import { TextButtonComponent } from '@isa/ui/buttons';
import { Router } from '@angular/router';
import { AddCustomerCardComponent } from './components/add-customer-card/add-customer-card.component';
/**
* Main container component for displaying customer loyalty cards.
@@ -30,8 +31,8 @@ import { Router } from '@angular/router';
CustomerCardPointsSummaryComponent,
CustomerCardsCarouselComponent,
TextButtonComponent,
AddCustomerCardComponent,
],
providers: [CustomerBonusCardsResource],
templateUrl: './customer-loyalty-cards.component.html',
styleUrl: './customer-loyalty-cards.component.css',
})
@@ -41,7 +42,7 @@ export class CustomerLoyaltyCardsComponent {
#bonusCardsResource = inject(CustomerBonusCardsResource);
#logger = logger(() => ({
context: 'CustomerLoyaltyCardsComponent',
component: 'CustomerLoyaltyCardsComponent',
}));
/**

View File

@@ -12,6 +12,6 @@
class="isa-text-body-1-bold text-isa-neutral-900 whitespace-pre-line text-center"
data-what="error-message"
>
{{ data.errorMessage ?? 'Unbekannter Fehler' }}
{{ errorMessage }}
</p>
</div>

View File

@@ -7,12 +7,9 @@ import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog';
import { NgIcon } from '@ng-icons/core';
import { DialogComponent } from '../dialog.component';
// Test suite for FeedbackErrorDialogComponent
describe('FeedbackErrorDialogComponent', () => {
let spectator: Spectator<FeedbackErrorDialogComponent>;
const mockData: FeedbackErrorDialogData = {
errorMessage: 'Something went wrong',
};
let currentDialogData: FeedbackErrorDialogData;
const createComponent = createComponentFactory({
component: FeedbackErrorDialogComponent,
@@ -24,7 +21,7 @@ describe('FeedbackErrorDialogComponent', () => {
},
{
provide: DIALOG_DATA,
useValue: mockData,
useFactory: () => currentDialogData,
},
{
provide: DialogComponent,
@@ -33,24 +30,73 @@ describe('FeedbackErrorDialogComponent', () => {
],
});
beforeEach(() => {
spectator = createComponent();
jest.clearAllMocks();
});
it('should create', () => {
// Arrange
currentDialogData = { errorMessage: 'Test error' };
// Act
spectator = createComponent();
// Assert
expect(spectator.component).toBeTruthy();
});
it('should display the error message passed in data', () => {
const messageElement = spectator.query('[data-what="error-message"]');
expect(messageElement).toHaveText('Something went wrong');
describe('error message display', () => {
it('should display error message from errorMessage property', () => {
// Arrange
currentDialogData = {
errorMessage: 'Something went wrong',
};
spectator = createComponent();
// Act
const messageElement = spectator.query('[data-what="error-message"]');
// Assert
expect(messageElement).toHaveText('Something went wrong');
});
it('should display error message from Error object', () => {
// Arrange
const testError = new Error('Test error message');
currentDialogData = { error: testError };
spectator = createComponent();
// Act
const messageElement = spectator.query('[data-what="error-message"]');
// Assert
expect(messageElement).toHaveText('Test error message');
});
it('should display fallback message for unknown error type', () => {
// Arrange
currentDialogData = { error: 'invalid error' };
spectator = createComponent();
// Act
const messageElement = spectator.query('[data-what="error-message"]');
// Assert
expect(messageElement).toHaveText(
'Ein unbekannter Fehler ist aufgetreten',
);
});
});
it('should render the close icon', () => {
// The icon should be present with isaActionClose
const iconElement = spectator.query('ng-icon');
expect(iconElement).toBeTruthy();
expect(iconElement).toHaveAttribute('name', 'isaActionClose');
describe('icon rendering', () => {
it('should render the close icon with correct attributes', () => {
// Arrange
currentDialogData = { errorMessage: 'Test error' };
spectator = createComponent();
// Act
const iconElement = spectator.query('ng-icon');
// Assert
expect(iconElement).toBeTruthy();
expect(iconElement).toHaveAttribute('name', 'isaActionClose');
expect(iconElement).toHaveAttribute('size', '1.5rem');
});
});
});

View File

@@ -2,15 +2,23 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
import { DialogContentDirective } from '../dialog-content.directive';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
import { logger } from '@isa/core/logging';
/**
* Input data for the error message dialog
*/
export interface FeedbackErrorDialogData {
export interface FeedbackErrorMessageDialogData {
/** The Error message text to display in the dialog */
errorMessage?: string;
errorMessage: string;
}
export type FeedbackErrorDialogData =
| FeedbackErrorMessageDialogData
| {
/** The error to display in the dialog */
error: unknown;
};
/**
* A simple feedback dialog component that displays an error message and an error icon.
*/
@@ -27,4 +35,23 @@ export interface FeedbackErrorDialogData {
export class FeedbackErrorDialogComponent extends DialogContentDirective<
FeedbackErrorDialogData,
void
> {}
> {
#logger = logger(() => ({
component: 'FeedbackErrorDialogComponent',
}));
get errorMessage() {
if ('errorMessage' in this.data) {
return this.data.errorMessage;
} else if ('error' in this.data) {
const error = this.data.error;
if (error instanceof Error) {
return error.message;
}
}
this.#logger.warn('No valid error message found', () => ({
data: this.data,
}));
return 'Ein unbekannter Fehler ist aufgetreten';
}
}

View File

@@ -1,34 +1,70 @@
<p class="isa-text-body-1-regular text-isa-neutral-600" data-what="message">
<p
class="isa-text-body-1-regular text-isa-neutral-600"
data-what="text"
data-which="dialog-message"
role="status"
>
{{ data.message }}
</p>
<div class="flex flex-col gap-8">
<ui-text-field-container>
<ui-text-field size="small" class="w-full">
<input
#inputRef
uiInputControl
class="isa-text-body-2-bold placeholder:isa-text-body-2-bold"
[placeholder]="data?.inputLabel ?? ''"
type="text"
[formControl]="control"
(cleared)="control.setValue('')"
(blur)="control.updateValueAndValidity()"
(keydown.enter)="close({ inputValue: control.value })"
/>
<div class="flex flex-row gap-4">
<ui-text-field-container>
<ui-text-field size="small" class="w-[22rem] desktop-small:w-[26rem]">
<input
#inputRef
uiInputControl
class="isa-text-body-2-bold placeholder:isa-text-body-2-bold"
[placeholder]="data?.inputLabel ?? ''"
[attr.aria-label]="data?.inputLabel ?? 'Texteingabe'"
[attr.aria-invalid]="control.invalid && control.touched"
[attr.aria-required]="true"
data-what="input"
data-which="dialog-text-input"
type="text"
[formControl]="control"
(cleared)="control.setValue('')"
(blur)="control.updateValueAndValidity()"
(keydown.enter)="onConfirm()"
/>
<ui-text-field-clear></ui-text-field-clear>
</ui-text-field>
<ui-text-field-clear></ui-text-field-clear>
</ui-text-field>
@if (data?.inputValidation) {
<ui-text-field-errors>
@for (validation of data.inputValidation; track validation.errorKey) {
@if (control.errors?.[validation.errorKey] && control.touched) {
<span>{{ validation.errorText }}</span>
@if (data?.inputValidation || asyncError()) {
<ui-text-field-errors>
@if (asyncError()) {
<span
data-what="error"
data-which="async-error"
role="alert"
aria-live="polite"
>{{ asyncError() }}</span
>
}
}
</ui-text-field-errors>
}
</ui-text-field-container>
@for (validation of data.inputValidation; track validation.errorKey) {
@if (control.errors?.[validation.errorKey] && control.touched) {
<span
data-what="error"
[attr.data-which]="'validation-' + validation.errorKey"
role="alert"
>{{ validation.errorText }}</span
>
}
}
</ui-text-field-errors>
}
</ui-text-field-container>
<shared-scanner-button
class="self-start"
data-what="button"
data-which="scanner"
aria-label="Barcode scannen"
[disabled]="!!control?.value"
(scan)="onScan($event)"
>
</shared-scanner-button>
</div>
<div class="flex flex-row gap-2 w-full">
<button
@@ -38,17 +74,19 @@
color="secondary"
data-what="button"
data-which="close"
[disabled]="isLoading()"
>
{{ data.closeText || 'Verlassen' }}
</button>
<button
class="grow"
uiButton
(click)="close({ inputValue: control.value })"
(click)="onConfirm()"
color="primary"
data-what="button"
data-which="save"
[disabled]="control.invalid"
[disabled]="control.invalid || isLoading()"
[pending]="isLoading()"
>
{{ data.confirmText || 'Speichern' }}
</button>

View File

@@ -3,7 +3,7 @@ import {
TextInputDialogComponent,
TextInputDialogData,
} from './text-input-dialog.component';
import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog'; // Adjust if your tokens are elsewhere
import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog';
import { ButtonComponent } from '@isa/ui/buttons';
import {
InputControlDirective,
@@ -13,82 +13,277 @@ import {
TextFieldErrorsComponent,
} from '@isa/ui/input-controls';
import { DialogComponent } from '../dialog.component';
import { ScannerService } from '@isa/shared/scanner';
import { CONFIG_DATA } from '@isa/core/config';
import { signal } from '@angular/core';
// Mock Scandit modules to avoid loading external dependencies
jest.mock('scandit-web-datacapture-core', () => ({}));
jest.mock('scandit-web-datacapture-barcode', () => ({}));
describe('TextInputDialogComponent', () => {
let spectator: Spectator<TextInputDialogComponent>;
let mockDialogRef: DialogRef<any>;
const mockData: TextInputDialogData = {
message: 'Please enter your name',
inputLabel: 'Name',
inputDefaultValue: 'John Doe',
closeText: 'Cancel',
confirmText: 'Save',
};
let mockScannerService: jest.Mocked<ScannerService>;
let mockData: TextInputDialogData;
const createComponent = createComponentFactory({
component: TextInputDialogComponent,
imports: [
ButtonComponent,
InputControlDirective,
TextFieldClearComponent,
TextFieldComponent,
TextFieldContainerComponent,
TextFieldErrorsComponent,
],
mocks: [ScannerService],
detectChanges: false,
providers: [
{ provide: DialogRef, useValue: { close: jest.fn() } },
{ provide: DIALOG_DATA, useValue: mockData },
{
provide: DIALOG_DATA,
useFactory: () => mockData,
},
{ provide: DialogComponent, useValue: {} },
{ provide: CONFIG_DATA, useValue: {} },
],
});
it('should create', () => {
beforeEach(() => {
// Reset mockData before each test
mockData = {
message: 'Please enter your name',
inputLabel: 'Name',
inputDefaultValue: 'John Doe',
closeText: 'Cancel',
confirmText: 'Save',
};
spectator = createComponent();
mockDialogRef = spectator.inject(DialogRef);
mockScannerService = spectator.inject(ScannerService);
// Add ready signal to mocked scanner service
(mockScannerService as any).ready = signal(true);
jest.clearAllMocks();
spectator.detectChanges();
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
it('should render the dialog message', () => {
spectator = createComponent();
expect(spectator.query('[data-what="message"]')).toHaveText(
'Please enter your name',
);
});
it('should render the scanner button', () => {
const scannerButton = spectator.query('shared-scanner-button');
expect(scannerButton).toBeTruthy();
});
it('should update the control value when input changes', () => {
spectator = createComponent();
const input = spectator.query('input') as HTMLInputElement;
spectator.typeInElement('Jane', input);
spectator.detectChanges();
expect(spectator.component.control.value).toBe('Jane');
});
it('should handle error case: inputValidation is undefined', () => {
spectator = createComponent({
providers: [
{
provide: DIALOG_DATA,
useValue: { ...mockData, inputValidation: undefined },
},
],
});
spectator.detectChanges();
const input = spectator.query('input');
expect(input).toBeTruthy();
it('should update control value when onScan is called', () => {
const scannedValue = '1234567890';
spectator.component.onScan(scannedValue);
expect(spectator.component.control.value).toBe(scannedValue);
});
it('should handle error case: inputLabel is missing', () => {
spectator = createComponent({
providers: [
{
provide: DIALOG_DATA,
useValue: { ...mockData, inputLabel: undefined },
},
],
});
it('should disable scanner button when input has value', () => {
spectator.component.control.setValue('Some value');
spectator.detectChanges();
const input = spectator.query('input');
expect(input).toBeTruthy();
const scannerButton = spectator.query('shared-scanner-button');
expect(scannerButton).toBeTruthy();
// The button component binds disabled input [disabled]="!!control?.value"
// Since control.value is truthy, disabled should be true
const debugElement = spectator.debugElement.query(
(el) => el.nativeElement.tagName === 'SHARED-SCANNER-BUTTON',
);
// disabled is a signal model, need to call it as a function
expect(debugElement?.componentInstance.disabled()).toBe(true);
});
it('should close dialog with scanned value when save button is clicked after scanning', () => {
const scannedValue = 'SCAN123456';
spectator.component.onScan(scannedValue);
spectator.detectChanges();
const saveButton = spectator.query(
'[data-which="save"]',
) as HTMLButtonElement;
spectator.click(saveButton);
expect(mockDialogRef.close).toHaveBeenCalledWith({
inputValue: scannedValue,
});
});
describe('onConfirm with async callback', () => {
it('should execute onConfirm callback and close dialog on success', async () => {
const mockOnConfirm = jest.fn().mockResolvedValue(undefined);
mockData.onConfirm = mockOnConfirm;
spectator.component.control.setValue('test-value');
spectator.detectChanges();
await spectator.component.onConfirm();
expect(mockOnConfirm).toHaveBeenCalledWith('test-value');
expect(mockDialogRef.close).toHaveBeenCalledWith({
inputValue: 'test-value',
});
expect(spectator.component.asyncError()).toBeNull();
});
it('should set isLoading to true during async operation', async () => {
let resolveCallback: () => void;
const mockOnConfirm = jest.fn(
() =>
new Promise<void>((resolve) => {
resolveCallback = resolve;
}),
);
mockData.onConfirm = mockOnConfirm;
spectator.component.control.setValue('test-value');
const confirmPromise = spectator.component.onConfirm();
// Should be loading during the operation
expect(spectator.component.isLoading()).toBe(true);
resolveCallback!();
await confirmPromise;
// After success, dialog is closed so we don't test isLoading state
expect(mockDialogRef.close).toHaveBeenCalled();
});
it('should set asyncError and keep dialog open on callback failure', async () => {
const errorMessage = 'Backend validation failed';
const mockOnConfirm = jest
.fn()
.mockRejectedValue(new Error(errorMessage));
mockData.onConfirm = mockOnConfirm;
spectator.component.control.setValue('invalid-value');
spectator.detectChanges();
await spectator.component.onConfirm();
expect(mockOnConfirm).toHaveBeenCalledWith('invalid-value');
expect(mockDialogRef.close).not.toHaveBeenCalled();
expect(spectator.component.isLoading()).toBe(false);
expect(spectator.component.asyncError()).toBe(errorMessage);
});
it('should display asyncError in template', async () => {
const errorMessage = 'Card does not exist';
const mockOnConfirm = jest
.fn()
.mockRejectedValue(new Error(errorMessage));
mockData.onConfirm = mockOnConfirm;
spectator.component.control.setValue('ERROR');
spectator.detectChanges();
await spectator.component.onConfirm();
spectator.detectChanges();
const errorElement = spectator.query('ui-text-field-errors span');
expect(errorElement).toHaveText(errorMessage);
});
it('should use default error message when error has no message', async () => {
const mockOnConfirm = jest.fn().mockRejectedValue({});
mockData.onConfirm = mockOnConfirm;
spectator.component.control.setValue('test');
await spectator.component.onConfirm();
expect(spectator.component.asyncError()).toBe(
'Ein Fehler ist aufgetreten',
);
});
});
describe('asyncError clearing on input change', () => {
it('should clear asyncError when user starts typing', async () => {
// Set up component with error
const mockOnConfirm = jest
.fn()
.mockRejectedValue(new Error('Initial error'));
mockData.onConfirm = mockOnConfirm;
spectator.component.control.setValue('initial');
await spectator.component.onConfirm();
expect(spectator.component.asyncError()).toBe('Initial error');
// User types new value
const input = spectator.query('input') as HTMLInputElement;
spectator.typeInElement('new-value', input);
spectator.detectChanges();
// Error should be cleared
expect(spectator.component.asyncError()).toBeNull();
});
it('should clear asyncError when control value changes programmatically', async () => {
// Set up component with error
spectator.component.asyncError.set('Some error');
spectator.detectChanges();
expect(spectator.component.asyncError()).toBe('Some error');
// Change value programmatically
spectator.component.control.setValue('new-value');
spectator.detectChanges();
// Error should be cleared
expect(spectator.component.asyncError()).toBeNull();
});
});
describe('button states', () => {
it('should disable save button when control is invalid', () => {
// Add a required validator to make control invalid
spectator.component.control.addValidators((control) =>
control.value ? null : { required: true },
);
spectator.component.control.setValue('');
spectator.component.control.markAsTouched();
spectator.component.control.updateValueAndValidity();
spectator.detectChanges();
const saveButton = spectator.query(
'[data-which="save"]',
) as HTMLButtonElement;
expect(saveButton.disabled).toBe(true);
});
it('should disable both buttons when isLoading is true', () => {
spectator.component.isLoading.set(true);
spectator.detectChanges();
const closeButton = spectator.query(
'[data-which="close"]',
) as HTMLButtonElement;
const saveButton = spectator.query(
'[data-which="save"]',
) as HTMLButtonElement;
expect(closeButton.disabled).toBe(true);
expect(saveButton.disabled).toBe(true);
});
it('should set pending state on save button when isLoading', () => {
spectator.component.isLoading.set(true);
spectator.detectChanges();
const saveButton = spectator.query('[data-which="save"]');
const debugElement = spectator.debugElement.query(
(el) => el.nativeElement === saveButton,
);
// The [pending] input should be bound to isLoading()
expect(debugElement?.componentInstance.pending()).toBe(true);
});
});
});

View File

@@ -1,15 +1,19 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
effect,
signal,
untracked,
} from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
import { DialogContentDirective } from '../dialog-content.directive';
import {
ValidatorFn,
FormControl,
ReactiveFormsModule,
AsyncValidatorFn,
} from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
import {
InputControlDirective,
TextFieldClearComponent,
@@ -17,6 +21,9 @@ import {
TextFieldContainerComponent,
TextFieldErrorsComponent,
} from '@isa/ui/input-controls';
import { ScannerButtonComponent } from '@isa/shared/scanner';
import { provideIcons } from '@ng-icons/core';
import { isaActionScanner } from '@isa/icons';
/**
* Input data for the message dialog
@@ -39,6 +46,9 @@ export interface TextInputDialogData {
/** Optional custom text for the confirm button (defaults to "Speichern" or equivalent) */
confirmText?: string;
/** Optional async callback that is called when confirm button is clicked. If provided, dialog stays open until resolved/rejected */
onConfirm?: (inputValue: string) => Promise<unknown>;
}
export interface TextInputValidation {
@@ -57,11 +67,6 @@ export interface TextInputDialogResult {
inputValue?: string;
}
/**
* Simple message dialog component
* Used for displaying informational messages to the user
* Returns void when closed (no result)
*/
@Component({
selector: 'ui-text-input-dialog',
templateUrl: './text-input-dialog.component.html',
@@ -74,7 +79,9 @@ export interface TextInputDialogResult {
TextFieldComponent,
TextFieldContainerComponent,
TextFieldErrorsComponent,
ScannerButtonComponent,
],
providers: [provideIcons({ isaActionScanner })],
})
export class TextInputDialogComponent extends DialogContentDirective<
TextInputDialogData,
@@ -86,4 +93,48 @@ export class TextInputDialogComponent extends DialogContentDirective<
?.map((v) => v.inputValidator)
.filter(Boolean) as ValidatorFn[]) ?? [],
);
#controlValue = toSignal(this.control.valueChanges);
isLoading = signal(false);
asyncError = signal<string | null>(null);
constructor() {
super();
effect(() => {
this.#controlValue();
untracked(() => {
if (this.asyncError()) {
this.asyncError.set(null);
}
});
});
}
onScan(value: string): void {
this.control.setValue(value);
}
async onConfirm(): Promise<void> {
if (this.control.invalid) {
return;
}
const inputValue = this.control.value ?? '';
// If no callback provided, close immediately with result
if (!this.data?.onConfirm) {
this.close({ inputValue });
return;
}
try {
this.isLoading.set(true);
this.asyncError.set(null);
await this.data.onConfirm(inputValue);
this.close({ inputValue });
} catch (error: any) {
this.isLoading.set(false);
this.asyncError.set(error?.message ?? 'Ein Fehler ist aufgetreten');
}
}
}