mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 2033: Add, Lock, Unlock Customer Cards
Add, Lock, Unlock Customer Cards Refs: #5313, #5329, #5334, #5335
This commit is contained in:
committed by
Lorenz Hilpert
parent
b7d008e339
commit
17cb0802c3
@@ -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];
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
13
libs/crm/data-access/src/lib/schemas/add-card.schema.ts
Normal file
13
libs/crm/data-access/src/lib/schemas/add-card.schema.ts
Normal 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>;
|
||||
@@ -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';
|
||||
|
||||
8
libs/crm/data-access/src/lib/schemas/lock-card.schema.ts
Normal file
8
libs/crm/data-access/src/lib/schemas/lock-card.schema.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
class="w-40"
|
||||
uiButton
|
||||
type="button"
|
||||
size="large"
|
||||
color="primary"
|
||||
(click)="booking()"
|
||||
[disabled]="disableBooking()"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user