Compare commits

...

4 Commits

Author SHA1 Message Date
Nino
a4241cbd7a Merge branch 'release/4.0' 2025-08-14 16:40:46 +02:00
Nino Righi
ac728f2dd9 Merged PR 1912: hotfix(isa-app-ui/shared-searchbox): improve component initialization and met...
hotfix(isa-app-ui/shared-searchbox): improve component initialization and method safety

Enhance searchbox component reliability by addressing initialization
issues and improving method safety across both shared and ui implementations.

Key changes:
- Fix potential null reference errors in cancel search functionality
- Improve method parameter typing with explicit defaults
- Add proper initialization for ControlValueAccessor callbacks
- Enhance component property initialization with explicit types
- Add hintCleared output event for better hint management

These changes resolve runtime errors and improve type safety
for the searchbox components used throughout the application.

Refs: #5245
2025-08-07 17:55:25 +00:00
Nino Righi
c2f393d249 Merged PR 1911: hotfix(isa-app-store, core-storage): prevent caching of erroneous user state
hotfix(isa-app-store, core-storage): prevent caching of erroneous user state

Remove shareReplay(1) operator from user state observable to ensure
fresh state retrieval on each request. This prevents the system from
retaining and reusing failed or invalid state data across multiple
operations.

The current implementation now makes two API calls (GET + POST) per
set operation to guarantee the latest state is always used, trading
performance for reliability in error scenarios.

Refs: #5270, #5249
2025-08-06 15:47:49 +00:00
Nino Righi
0addf392b6 Merged PR 1901: hotfix(return-summary): disable navigation during return processing
hotfix(return-summary): disable navigation during return processing

Replace Router navigation with Location.back() for better UX and add
disabled states to prevent user actions during pending operations.

Changes:
- Replace navigateBack() method with direct Location.back() calls
- Add returnItemsAndPrintReciptPending input to ReturnSummaryItemComponent
- Disable edit and back buttons when return operation is pending
- Update parent component to pass pending state to child components
- Fix template binding to use computed pending status signal

This prevents users from navigating away during critical return
operations and provides consistent disabled states across the UI.

Ref: #5257
2025-07-31 16:41:59 +00:00
10 changed files with 273 additions and 239 deletions

View File

@@ -1,18 +1,18 @@
import { Injectable } from '@angular/core';
import { Logger, LogLevel } from '@core/logger';
import { Store } from '@ngrx/store';
import { debounceTime, switchMap, takeUntil } from 'rxjs/operators';
import { RootState } from './root.state';
import packageInfo from 'packageJson';
import { environment } from '../../environments/environment';
import { Subject } from 'rxjs';
import { AuthService } from '@core/auth';
import { injectStorage, UserStorageProvider } from '@isa/core/storage';
import { isEqual } from 'lodash';
import { Injectable } from "@angular/core";
import { Logger, LogLevel } from "@core/logger";
import { Store } from "@ngrx/store";
import { debounceTime, switchMap, takeUntil } from "rxjs/operators";
import { RootState } from "./root.state";
import packageInfo from "packageJson";
import { environment } from "../../environments/environment";
import { Subject } from "rxjs";
import { AuthService } from "@core/auth";
import { injectStorage, UserStorageProvider } from "@isa/core/storage";
import { isEqual } from "lodash";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class RootStateService {
static LOCAL_STORAGE_KEY = 'ISA_APP_INITIALSTATE';
static LOCAL_STORAGE_KEY = "ISA_APP_INITIALSTATE";
#storage = injectStorage(UserStorageProvider);
@@ -29,14 +29,17 @@ export class RootStateService {
);
}
window['clearUserState'] = () => {
window["clearUserState"] = () => {
this.clear();
};
}
async init() {
await this.load();
this._store.dispatch({ type: 'HYDRATE', payload: RootStateService.LoadFromLocalStorage() });
this._store.dispatch({
type: "HYDRATE",
payload: RootStateService.LoadFromLocalStorage(),
});
this.initSave();
}
@@ -50,14 +53,10 @@ export class RootStateService {
const data = {
...state,
version: packageInfo.version,
sub: this._authService.getClaimByKey('sub'),
sub: this._authService.getClaimByKey("sub"),
};
RootStateService.SaveToLocalStorageRaw(JSON.stringify(data));
return this.#storage.set('state', {
...state,
version: packageInfo.version,
sub: this._authService.getClaimByKey('sub'),
});
return this.#storage.set("state", data);
}),
)
.subscribe();
@@ -68,7 +67,7 @@ export class RootStateService {
*/
async load(): Promise<boolean> {
try {
const res = await this.#storage.get('state');
const res = await this.#storage.get("state");
const storageContent = RootStateService.LoadFromLocalStorageRaw();
@@ -88,7 +87,7 @@ export class RootStateService {
async clear() {
try {
this._cancelSave.next();
await this.#storage.clear('state');
await this.#storage.clear("state");
await new Promise((resolve) => setTimeout(resolve, 100));
RootStateService.RemoveFromLocalStorage();
await new Promise((resolve) => setTimeout(resolve, 100));
@@ -112,7 +111,7 @@ export class RootStateService {
try {
return JSON.parse(raw);
} catch (error) {
console.error('Error parsing local storage:', error);
console.error("Error parsing local storage:", error);
this.RemoveFromLocalStorage();
}
}

View File

@@ -16,20 +16,20 @@ import {
forwardRef,
Optional,
inject,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { UiAutocompleteComponent } from '@ui/autocomplete';
import { UiFormControlDirective } from '@ui/form-control';
import { containsElement } from '@utils/common';
import { Subscription } from 'rxjs';
import { ScanAdapterService } from '@adapter/scan';
import { injectCancelSearch } from '@shared/services/cancel-subject';
import { EnvironmentService } from '@core/environment';
} from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { UiAutocompleteComponent } from "@ui/autocomplete";
import { UiFormControlDirective } from "@ui/form-control";
import { containsElement } from "@utils/common";
import { Subscription } from "rxjs";
import { ScanAdapterService } from "@adapter/scan";
import { injectCancelSearch } from "@shared/services/cancel-subject";
import { EnvironmentService } from "@core/environment";
@Component({
selector: 'shared-searchbox',
templateUrl: 'searchbox.component.html',
styleUrls: ['searchbox.component.scss'],
selector: "shared-searchbox",
templateUrl: "searchbox.component.html",
styleUrls: ["searchbox.component.scss"],
providers: [
{
provide: NG_VALUE_ACCESSOR,
@@ -49,9 +49,9 @@ export class SearchboxComponent
cancelSearch = injectCancelSearch({ optional: true });
disabled: boolean;
type = 'text';
type = "text";
@ViewChild('input', { read: ElementRef, static: true })
@ViewChild("input", { read: ElementRef, static: true })
input: ElementRef;
@ContentChild(UiAutocompleteComponent)
@@ -61,9 +61,9 @@ export class SearchboxComponent
focusAfterViewInit = true;
@Input()
placeholder = '';
placeholder = "";
private _query = '';
private _query = "";
@Input()
get query() {
@@ -94,7 +94,7 @@ export class SearchboxComponent
scanner = false;
@Input()
hint = '';
hint = "";
@Input()
autocompleteValueSelector: (item: any) => string = (item: any) => item;
@@ -104,11 +104,11 @@ export class SearchboxComponent
}
clear(): void {
this.setQuery('');
this.setQuery("");
this.cancelSearch();
}
@HostBinding('class.autocomplete-opend')
@HostBinding("class.autocomplete-opend")
get autocompleteOpen() {
return this.autocomplete?.opend;
}
@@ -213,13 +213,13 @@ export class SearchboxComponent
}
clearHint() {
this.hint = '';
this.hint = "";
this.focused.emit(true);
this.cdr.markForCheck();
}
onKeyup(event: KeyboardEvent) {
if (event.key === 'Enter') {
if (event.key === "Enter") {
if (this.autocomplete?.opend && this.autocomplete?.activeItem) {
this.setQuery(this.autocomplete?.activeItem?.item);
this.autocomplete?.close();
@@ -227,7 +227,7 @@ export class SearchboxComponent
this.search.emit(this.query);
event.preventDefault();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
this.handleArrowUpDownEvent(event);
}
}
@@ -242,7 +242,7 @@ export class SearchboxComponent
}
}
@HostListener('window:click', ['$event'])
@HostListener("window:click", ["$event"])
focusLost(event: MouseEvent) {
if (
this.autocomplete?.opend &&
@@ -256,9 +256,11 @@ export class SearchboxComponent
this.search.emit(this.query);
}
@HostListener('focusout', ['$event'])
@HostListener("focusout", ["$event"])
onBlur() {
this.onTouched();
if (typeof this.onTouched === "function") {
this.onTouched();
}
this.focused.emit(false);
this.cdr.markForCheck();
}

View File

@@ -16,20 +16,20 @@ import {
forwardRef,
Optional,
inject,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { UiAutocompleteComponent } from '@ui/autocomplete';
import { UiFormControlDirective } from '@ui/form-control';
import { Subscription } from 'rxjs';
import { ScanAdapterService } from '@adapter/scan';
import { injectCancelSearch } from '@shared/services/cancel-subject';
import { containsElement } from '@utils/common';
import { EnvironmentService } from '@core/environment';
} from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { UiAutocompleteComponent } from "@ui/autocomplete";
import { UiFormControlDirective } from "@ui/form-control";
import { Subscription } from "rxjs";
import { ScanAdapterService } from "@adapter/scan";
import { injectCancelSearch } from "@shared/services/cancel-subject";
import { containsElement } from "@utils/common";
import { EnvironmentService } from "@core/environment";
@Component({
selector: 'ui-searchbox',
templateUrl: 'searchbox.component.html',
styleUrls: ['searchbox.component.scss'],
selector: "ui-searchbox",
templateUrl: "searchbox.component.html",
styleUrls: ["searchbox.component.scss"],
providers: [
{
provide: NG_VALUE_ACCESSOR,
@@ -49,9 +49,9 @@ export class UiSearchboxNextComponent
private readonly _cancelSearch = injectCancelSearch({ optional: true });
disabled: boolean;
type = 'text';
type = "text";
@ViewChild('input', { read: ElementRef, static: true })
@ViewChild("input", { read: ElementRef, static: true })
input: ElementRef;
@ContentChild(UiAutocompleteComponent)
@@ -61,9 +61,9 @@ export class UiSearchboxNextComponent
focusAfterViewInit: boolean = true;
@Input()
placeholder: string = '';
placeholder: string = "";
private _query = '';
private _query = "";
@Input()
get query() {
@@ -94,7 +94,7 @@ export class UiSearchboxNextComponent
scanner = false;
@Input()
hint: string = '';
hint: string = "";
@Output()
hintCleared = new EventEmitter<void>();
@@ -107,11 +107,11 @@ export class UiSearchboxNextComponent
}
clear(): void {
this.setQuery('');
this.setQuery("");
this._cancelSearch();
}
@HostBinding('class.autocomplete-opend')
@HostBinding("class.autocomplete-opend")
get autocompleteOpen() {
return this.autocomplete?.opend;
}
@@ -212,14 +212,14 @@ export class UiSearchboxNextComponent
}
clearHint() {
this.hint = '';
this.hint = "";
this.focused.emit(true);
this.hintCleared.emit();
this.cdr.markForCheck();
}
onKeyup(event: KeyboardEvent) {
if (event.key === 'Enter') {
if (event.key === "Enter") {
if (this.autocomplete?.opend && this.autocomplete?.activeItem) {
this.setQuery(this.autocomplete?.activeItem?.item);
this.autocomplete?.close();
@@ -227,7 +227,7 @@ export class UiSearchboxNextComponent
this.search.emit(this.query);
event.preventDefault();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
this.handleArrowUpDownEvent(event);
}
}
@@ -235,12 +235,14 @@ export class UiSearchboxNextComponent
handleArrowUpDownEvent(event: KeyboardEvent) {
this.autocomplete?.handleKeyboardEvent(event);
if (this.autocomplete?.activeItem) {
const query = this.autocompleteValueSelector(this.autocomplete.activeItem.item);
const query = this.autocompleteValueSelector(
this.autocomplete.activeItem.item,
);
this.setQuery(query, false, false);
}
}
@HostListener('window:click', ['$event'])
@HostListener("window:click", ["$event"])
focusLost(event: MouseEvent) {
if (
this.autocomplete?.opend &&
@@ -254,9 +256,11 @@ export class UiSearchboxNextComponent
this.search.emit(this.query);
}
@HostListener('focusout', ['$event'])
@HostListener("focusout", ["$event"])
onBlur() {
this.onTouched();
if (typeof this.onTouched === "function") {
this.onTouched();
}
this.focused.emit(false);
this.cdr.markForCheck();
}

View File

@@ -1,27 +1,42 @@
import { inject, Injectable } from '@angular/core';
import { StorageProvider } from './storage-provider';
import { UserStateService } from '@generated/swagger/isa-api';
import { firstValueFrom, map, shareReplay } from 'rxjs';
import { inject, Injectable } from "@angular/core";
import { StorageProvider } from "./storage-provider";
import { UserStateService } from "@generated/swagger/isa-api";
import { catchError, firstValueFrom, map, of } from "rxjs";
import { isEmpty } from "lodash";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class UserStorageProvider implements StorageProvider {
#userStateService = inject(UserStateService);
private state$ = this.#userStateService.UserStateGetUserState().pipe(
map((res) => {
if (res.result?.content) {
if (res?.result?.content) {
return JSON.parse(res.result.content);
}
return {};
}),
shareReplay(1),
catchError((err) => {
console.warn(
"No UserStateGetUserState found, returning empty object:",
err,
);
return of({}); // Return empty state fallback
}),
// shareReplay(1), #5249, #5270 Würde beim Fehlerfall den fehlerhaften Zustand behalten
// Aktuell wird nun jedes mal 2 mal der UserState aufgerufen (GET + POST)
// Damit bei der set Funktion immer der aktuelle Zustand verwendet wird
);
async set(key: string, value: unknown): Promise<void> {
async set(key: string, value: Record<string, unknown>): Promise<void> {
const current = await firstValueFrom(this.state$);
firstValueFrom(
const content =
current && !isEmpty(current)
? { ...current, [key]: value }
: { [key]: value };
await firstValueFrom(
this.#userStateService.UserStateSetUserState({
content: JSON.stringify({ ...current, [key]: value }),
content: JSON.stringify(content),
}),
);
}
@@ -32,7 +47,6 @@ export class UserStorageProvider implements StorageProvider {
}
async clear(key: string): Promise<void> {
const current = await firstValueFrom(this.state$);
delete current[key];
firstValueFrom(this.#userStateService.UserStateResetUserState());

View File

@@ -35,6 +35,7 @@
name="isaActionEdit"
data-what="button"
data-which="edit-return-item"
(click)="navigateBack()"
[disabled]="returnItemsAndPrintReciptPending()"
(click)="location.back()"
></ui-icon-button>
</div>

View File

@@ -1,16 +1,16 @@
import { createRoutingFactory, Spectator } from '@ngneat/spectator/jest';
import { ReturnSummaryItemComponent } from './return-summary-item.component';
import { MockComponents, MockProvider } from 'ng-mocks';
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
import { createRoutingFactory, Spectator } from "@ngneat/spectator/jest";
import { ReturnSummaryItemComponent } from "./return-summary-item.component";
import { MockComponents, MockProvider } from "ng-mocks";
import { ReturnProductInfoComponent } from "@isa/oms/shared/product-info";
import {
Product,
ReturnProcess,
ReturnProcessQuestionKey,
ReturnProcessService,
} from '@isa/oms/data-access';
import { NgIcon } from '@ng-icons/core';
import { IconButtonComponent } from '@isa/ui/buttons';
import { Router } from '@angular/router';
} from "@isa/oms/data-access";
import { NgIcon } from "@ng-icons/core";
import { IconButtonComponent } from "@isa/ui/buttons";
import { Location } from "@angular/common";
/**
* Creates a mock ReturnProcess with default values that can be overridden
@@ -21,20 +21,20 @@ function createMockReturnProcess(
return {
id: 1,
processId: 1,
productCategory: 'Electronics',
productCategory: "Electronics",
answers: {},
receiptId: 123,
receiptItem: {
id: 321,
product: {
name: 'Test Product',
name: "Test Product",
},
},
...partial,
} as ReturnProcess;
}
describe('ReturnSummaryItemComponent', () => {
describe("ReturnSummaryItemComponent", () => {
let spectator: Spectator<ReturnSummaryItemComponent>;
let returnProcessService: jest.Mocked<ReturnProcessService>;
@@ -48,7 +48,10 @@ describe('ReturnSummaryItemComponent', () => {
providers: [
MockProvider(ReturnProcessService, {
getReturnInfo: jest.fn(),
eligibleForReturn: jest.fn().mockReturnValue({ state: 'eligible' }),
eligibleForReturn: jest.fn().mockReturnValue({ state: "eligible" }),
}),
MockProvider(Location, {
back: jest.fn(),
}),
],
shallow: true,
@@ -64,38 +67,38 @@ describe('ReturnSummaryItemComponent', () => {
spectator.detectChanges();
});
describe('Component Creation', () => {
it('should create the component', () => {
describe("Component Creation", () => {
it("should create the component", () => {
expect(spectator.component).toBeTruthy();
});
});
describe('Return Information Display', () => {
describe("Return Information Display", () => {
const mockReturnInfo = {
itemCondition: 'itemCondition',
returnDetails: { [ReturnProcessQuestionKey.CaseDamaged]: 'no' },
returnReason: 'returnReason',
itemCondition: "itemCondition",
returnDetails: { [ReturnProcessQuestionKey.CaseDamaged]: "no" },
returnReason: "returnReason",
otherProduct: {
ean: 'ean',
ean: "ean",
} as Product,
comment: 'comment',
comment: "comment",
};
beforeEach(() => {
jest
.spyOn(returnProcessService, 'getReturnInfo')
.spyOn(returnProcessService, "getReturnInfo")
.mockReturnValue(mockReturnInfo);
spectator.setInput('returnProcess', createMockReturnProcess({ id: 2 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 2 }));
spectator.detectChanges();
});
it('should provide correct return information array', () => {
it("should provide correct return information array", () => {
// Arrange
const expectedInfos = [
'itemCondition',
'returnReason',
'Gehäuse beschädigt: no',
'Geliefert wurde: ean',
'comment',
"itemCondition",
"returnReason",
"Gehäuse beschädigt: no",
"Geliefert wurde: ean",
"comment",
];
// Act
@@ -105,14 +108,14 @@ describe('ReturnSummaryItemComponent', () => {
expect(actualInfos).toEqual(expectedInfos);
expect(actualInfos.length).toBe(5);
});
it('should render return info items with correct content', () => {
it("should render return info items with correct content", () => {
// Arrange
const expectedInfos = [
'itemCondition',
'returnReason',
'Gehäuse beschädigt: no',
'Geliefert wurde: ean',
'comment',
"itemCondition",
"returnReason",
"Gehäuse beschädigt: no",
"Geliefert wurde: ean",
"comment",
];
// Act
@@ -125,14 +128,14 @@ describe('ReturnSummaryItemComponent', () => {
expect(listItems.length).toBe(expectedInfos.length);
listItems.forEach((item, index) => {
expect(item).toHaveText(expectedInfos[index]);
expect(item).toHaveAttribute('data-info-index', index.toString());
expect(item).toHaveAttribute("data-info-index", index.toString());
});
});
it('should handle undefined return info gracefully', () => {
it("should handle undefined return info gracefully", () => {
// Arrange
returnProcessService.getReturnInfo.mockReturnValue(undefined);
spectator.setInput('returnProcess', createMockReturnProcess({ id: 3 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 3 }));
spectator.detectChanges();
// Act
@@ -146,26 +149,26 @@ describe('ReturnSummaryItemComponent', () => {
expect(listItems.length).toBe(0);
});
describe('returnDetails mapping', () => {
it('should map multiple returnDetails keys to correct info strings', () => {
describe("returnDetails mapping", () => {
it("should map multiple returnDetails keys to correct info strings", () => {
const expected = [
'itemCondition',
'returnReason',
'Gehäuse beschädigt: Ja',
'Display beschädigt: Nein',
'Geliefert wurde: ean',
'comment',
"itemCondition",
"returnReason",
"Gehäuse beschädigt: Ja",
"Display beschädigt: Nein",
"Geliefert wurde: ean",
"comment",
];
// Arrange
const details = {
[ReturnProcessQuestionKey.CaseDamaged]: 'Ja',
[ReturnProcessQuestionKey.DisplayDamaged]: 'Nein',
[ReturnProcessQuestionKey.CaseDamaged]: "Ja",
[ReturnProcessQuestionKey.DisplayDamaged]: "Nein",
};
returnProcessService.getReturnInfo.mockReturnValue({
...mockReturnInfo,
returnDetails: details,
});
spectator.setInput('returnProcess', createMockReturnProcess({ id: 4 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 4 }));
spectator.detectChanges();
// Act
@@ -173,31 +176,31 @@ describe('ReturnSummaryItemComponent', () => {
expect(infos).toEqual(expected);
});
it('should not include returnDetails if empty', () => {
it("should not include returnDetails if empty", () => {
// Arrange
returnProcessService.getReturnInfo.mockReturnValue({
...mockReturnInfo,
returnDetails: {},
});
spectator.setInput('returnProcess', createMockReturnProcess({ id: 5 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 5 }));
spectator.detectChanges();
// Act
const infos = spectator.component.returnInfos();
// Assert
expect(infos.some((info) => info.includes('Gehäuse beschädigt'))).toBe(
expect(infos.some((info) => info.includes("Gehäuse beschädigt"))).toBe(
false,
);
expect(infos.some((info) => info.includes('Zubehör fehlt'))).toBe(
expect(infos.some((info) => info.includes("Zubehör fehlt"))).toBe(
false,
);
});
});
});
describe('Navigation', () => {
it('should render edit button with correct attributes', () => {
describe("Navigation", () => {
it("should render edit button with correct attributes", () => {
// Assert
const editButton = spectator.query(
'[data-what="button"][data-which="edit-return-item"]',
@@ -205,7 +208,7 @@ describe('ReturnSummaryItemComponent', () => {
expect(editButton).toExist();
});
it('should navigate back when edit button is clicked', () => {
it("should navigate back when edit button is clicked", () => {
// Arrange
const editButton = spectator.query(
'[data-what="button"][data-which="edit-return-item"]',
@@ -217,25 +220,20 @@ describe('ReturnSummaryItemComponent', () => {
}
// Assert
expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(
['..'],
expect.objectContaining({
relativeTo: expect.anything(),
}),
);
expect(spectator.inject(Location).back).toHaveBeenCalled();
});
});
it('should render the product info component', () => {
it("should render the product info component", () => {
const productInfo = spectator.query(ReturnProductInfoComponent);
expect(productInfo).toExist();
});
it('should compute eligibility state as eligible', () => {
it("should compute eligibility state as eligible", () => {
(returnProcessService.eligibleForReturn as jest.Mock).mockReturnValue({
state: 'eligible',
state: "eligible",
});
spectator.detectChanges();
expect(spectator.component.eligibleForReturn()?.state).toBe('eligible');
expect(spectator.component.eligibleForReturn()?.state).toBe("eligible");
});
});

View File

@@ -4,13 +4,13 @@ import {
computed,
inject,
input,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
} from "@angular/core";
import { Location } from "@angular/common";
import {
isaActionChevronRight,
isaActionClose,
isaActionEdit,
} from '@isa/icons';
} from "@isa/icons";
import {
EligibleForReturn,
EligibleForReturnState,
@@ -18,10 +18,10 @@ import {
ReturnProcessService,
ProductCategory,
returnDetailsMapping,
} from '@isa/oms/data-access';
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
import { IconButtonComponent } from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
} from "@isa/oms/data-access";
import { ReturnProductInfoComponent } from "@isa/oms/shared/product-info";
import { IconButtonComponent } from "@isa/ui/buttons";
import { NgIcon, provideIcons } from "@ng-icons/core";
/**
* Displays a single item in the return process summary, showing product details
@@ -47,30 +47,34 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
* ```
*/
@Component({
selector: 'oms-feature-return-summary-item',
templateUrl: './return-summary-item.component.html',
styleUrls: ['./return-summary-item.component.scss'],
selector: "oms-feature-return-summary-item",
templateUrl: "./return-summary-item.component.html",
styleUrls: ["./return-summary-item.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReturnProductInfoComponent, NgIcon, IconButtonComponent],
providers: [
provideIcons({ isaActionChevronRight, isaActionEdit, isaActionClose }),
],
host: {
'data-what': 'list-item',
'data-which': 'return-process-item',
'[attr.data-receipt-id]': 'returnProcess()?.receiptId',
'[attr.data-return-item-id]': 'returnProcess()?.returnItem?.id',
"data-what": "list-item",
"data-which": "return-process-item",
"[attr.data-receipt-id]": "returnProcess()?.receiptId",
"[attr.data-return-item-id]": "returnProcess()?.returnItem?.id",
},
})
export class ReturnSummaryItemComponent {
EligibleForReturnState = EligibleForReturnState;
#returnProcessService = inject(ReturnProcessService);
#router = inject(Router);
#activatedRoute = inject(ActivatedRoute);
/** Angular Location service for navigation */
location = inject(Location);
/** The return process object containing all information about the return */
returnProcess = input.required<ReturnProcess>();
/** The status of the return items and print receipt operation */
returnItemsAndPrintReciptPending = input<boolean>(false);
/**
* Computes whether the current return process is eligible for return.
*
@@ -149,8 +153,4 @@ export class ReturnSummaryItemComponent {
// remove duplicates
return Array.from(new Set(result));
});
navigateBack() {
this.#router.navigate(['..'], { relativeTo: this.#activatedRoute });
}
}

View File

@@ -3,6 +3,7 @@
color="tertiary"
size="small"
class="px-[0.875rem] py-1 min-w-0 bg-white gap-1 absolute top-0 left-0"
[disabled]="returnItemsAndPrintReciptStatusPending()"
(click)="location.back()"
>
<ng-icon name="isaActionChevronLeft" size="1.5rem" class="-ml-2"></ng-icon>
@@ -28,19 +29,22 @@
data-which="return-process-item"
[attr.data-item-id]="item.id"
[attr.data-item-category]="item.productCategory"
[returnItemsAndPrintReciptPending]="
returnItemsAndPrintReciptStatusPending()
"
></oms-feature-return-summary-item>
}
</div>
<div class="mt-6 text-center">
@if (returnItemsAndPrintReciptStatus() !== 'success') {
@if (returnItemsAndPrintReciptStatus() !== "success") {
<button
type="button"
size="large"
uiButton
color="brand"
(click)="returnItemsAndPrintRecipt()"
[pending]="returnItemsAndPrintReciptStatus() === 'pending'"
[disabled]="returnItemsAndPrintReciptStatus() === 'pending'"
[pending]="returnItemsAndPrintReciptStatusPending()"
[disabled]="returnItemsAndPrintReciptStatusPending()"
data-what="button"
data-which="return-and-print"
>

View File

@@ -1,30 +1,30 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockComponent } from 'ng-mocks';
import { ReturnSummaryComponent } from './return-summary.component';
import { createComponentFactory, Spectator } from "@ngneat/spectator/jest";
import { MockComponent } from "ng-mocks";
import { ReturnSummaryComponent } from "./return-summary.component";
import {
ReturnProcess,
ReturnProcessService,
ReturnProcessStore,
} from '@isa/oms/data-access';
import { ReturnSummaryItemComponent } from './return-summary-item/return-summary-item.component';
import { ActivatedRoute, Router } from '@angular/router';
} from "@isa/oms/data-access";
import { ReturnSummaryItemComponent } from "./return-summary-item/return-summary-item.component";
import { ActivatedRoute, Router } from "@angular/router";
jest.mock('@isa/core/process', () => ({
jest.mock("@isa/core/process", () => ({
injectActivatedProcessId: () => jest.fn(() => 1),
}));
const MOCK_RETURN_PROCESSES: ReturnProcess[] = [
{ id: 1, processId: 1, productCategory: 'Electronics' } as ReturnProcess,
{ id: 2, processId: 1, productCategory: 'Books' } as ReturnProcess,
{ id: 3, processId: 2, productCategory: 'Clothing' } as ReturnProcess,
{ id: 4, processId: 2, productCategory: 'Home Goods' } as ReturnProcess,
{ id: 5, processId: 3, productCategory: 'Electronics' } as ReturnProcess,
{ id: 6, processId: 3, productCategory: 'Toys' } as ReturnProcess,
{ id: 7, processId: 4, productCategory: 'Books' } as ReturnProcess,
{ id: 8, processId: 4, productCategory: 'Garden' } as ReturnProcess,
{ id: 1, processId: 1, productCategory: "Electronics" } as ReturnProcess,
{ id: 2, processId: 1, productCategory: "Books" } as ReturnProcess,
{ id: 3, processId: 2, productCategory: "Clothing" } as ReturnProcess,
{ id: 4, processId: 2, productCategory: "Home Goods" } as ReturnProcess,
{ id: 5, processId: 3, productCategory: "Electronics" } as ReturnProcess,
{ id: 6, processId: 3, productCategory: "Toys" } as ReturnProcess,
{ id: 7, processId: 4, productCategory: "Books" } as ReturnProcess,
{ id: 8, processId: 4, productCategory: "Garden" } as ReturnProcess,
];
describe('ReturnSummaryComponent', () => {
describe("ReturnSummaryComponent", () => {
let spectator: Spectator<ReturnSummaryComponent>;
// Use createComponentFactory for standalone components
const createComponent = createComponentFactory({
@@ -47,17 +47,17 @@ describe('ReturnSummaryComponent', () => {
});
});
it('should create the component', () => {
it("should create the component", () => {
// Assert: Check if the component instance was created successfully
expect(spectator.component).toBeTruthy();
});
it('should have a defined processId', () => {
it("should have a defined processId", () => {
// Assert: Check if the processId is defined
expect(spectator.component.processId()).toBeTruthy();
});
it('should have two return processes', () => {
it("should have two return processes", () => {
// Arrange:
const mockReturnProcesses: ReturnProcess[] = [
MOCK_RETURN_PROCESSES[0],
@@ -72,14 +72,14 @@ describe('ReturnSummaryComponent', () => {
expect(returnProcesses).toEqual(mockReturnProcesses);
});
it('should render the return summary item component', () => {
it("should render the return summary item component", () => {
// Arrange:
const mockReturnProcesses: ReturnProcess[] = [
MOCK_RETURN_PROCESSES[0],
MOCK_RETURN_PROCESSES[1],
];
jest
.spyOn(spectator.component, 'returnProcesses')
.spyOn(spectator.component, "returnProcesses")
.mockReturnValue(mockReturnProcesses);
// Act: Trigger change detection
@@ -91,14 +91,14 @@ describe('ReturnSummaryComponent', () => {
expect(returnSummaryItems.length).toBe(mockReturnProcesses.length);
});
it('should set the returnProcess input correctly', () => {
it("should set the returnProcess input correctly", () => {
// Arrange:
const mockReturnProcesses: ReturnProcess[] = [
MOCK_RETURN_PROCESSES[0],
MOCK_RETURN_PROCESSES[1],
];
jest
.spyOn(spectator.component, 'returnProcesses')
.spyOn(spectator.component, "returnProcesses")
.mockReturnValue(mockReturnProcesses);
// Act: Trigger change detection
@@ -110,7 +110,7 @@ describe('ReturnSummaryComponent', () => {
expect(returnSummaryItems[1].returnProcess).toEqual(mockReturnProcesses[1]);
});
it('should have proper E2E testing attributes', () => {
it("should have proper E2E testing attributes", () => {
// Arrange
const mockReturnProcesses: ReturnProcess[] = [
MOCK_RETURN_PROCESSES[0], // id: 1, processId: 1, category: 'Electronics'
@@ -118,7 +118,7 @@ describe('ReturnSummaryComponent', () => {
];
jest
.spyOn(spectator.component, 'returnProcesses')
.spyOn(spectator.component, "returnProcesses")
.mockReturnValue(mockReturnProcesses);
// Act
@@ -128,57 +128,57 @@ describe('ReturnSummaryComponent', () => {
// Check heading attributes
const heading = spectator.query('[data-what="heading"]');
expect(heading).toBeTruthy();
expect(heading).toHaveAttribute('data-which', 'return-summary-title');
expect(heading).toHaveAttribute("data-which", "return-summary-title");
// Check container attributes
const container = spectator.query('[data-what="container"]');
expect(container).toBeTruthy();
expect(container).toHaveAttribute('data-which', 'return-items-list');
expect(container).toHaveAttribute("data-which", "return-items-list");
// Check list item attributes
const listItems = spectator.queryAll('oms-feature-return-summary-item');
const listItems = spectator.queryAll("oms-feature-return-summary-item");
expect(listItems.length).toBe(mockReturnProcesses.length);
// Check attributes for the first item
expect(listItems[0]).toHaveAttribute('data-what', 'list-item');
expect(listItems[0]).toHaveAttribute('data-which', 'return-process-item');
expect(listItems[0]).toHaveAttribute("data-what", "list-item");
expect(listItems[0]).toHaveAttribute("data-which", "return-process-item");
expect(listItems[0]).toHaveAttribute(
'data-item-id',
"data-item-id",
`${mockReturnProcesses[0].id}`,
);
expect(listItems[0]).toHaveAttribute(
'data-item-category',
"data-item-category",
mockReturnProcesses[0].productCategory,
);
// Check attributes for the second item
expect(listItems[1]).toHaveAttribute('data-what', 'list-item');
expect(listItems[1]).toHaveAttribute('data-which', 'return-process-item');
expect(listItems[1]).toHaveAttribute("data-what", "list-item");
expect(listItems[1]).toHaveAttribute("data-which", "return-process-item");
expect(listItems[1]).toHaveAttribute(
'data-item-id',
"data-item-id",
`${mockReturnProcesses[1].id}`,
);
expect(listItems[1]).toHaveAttribute(
'data-item-category',
"data-item-category",
`${mockReturnProcesses[1].productCategory}`,
);
expect(listItems[1]).toHaveAttribute(
'data-item-category',
"data-item-category",
mockReturnProcesses[1].productCategory,
);
// Check button attributes
const button = spectator.query('[data-what="button"]');
expect(button).toBeTruthy();
expect(button).toHaveAttribute('data-which', 'return-and-print');
expect(button).toHaveAttribute("data-which", "return-and-print");
});
it('should call returnItemsAndPrintRecipt when button is clicked', () => {
it("should call returnItemsAndPrintRecipt when button is clicked", () => {
// Arrange: Spy on the returnItemsAndPrintRecipt method
const returnItemsAndPrintReciptSpy = jest.spyOn(
spectator.component,
'returnItemsAndPrintRecipt',
"returnItemsAndPrintRecipt",
);
// Act: Trigger button click

View File

@@ -4,20 +4,20 @@ import {
computed,
inject,
signal,
} from '@angular/core';
import { ReturnSummaryItemComponent } from './return-summary-item/return-summary-item.component';
import { injectActivatedProcessId } from '@isa/core/process';
} from "@angular/core";
import { ReturnSummaryItemComponent } from "./return-summary-item/return-summary-item.component";
import { injectActivatedProcessId } from "@isa/core/process";
import {
ReturnProcess,
ReturnProcessService,
ReturnProcessStore,
} from '@isa/oms/data-access';
import { ButtonComponent } from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Location } from '@angular/common';
import { isaActionChevronLeft } from '@isa/icons';
import { logger } from '@isa/core/logging';
import { ActivatedRoute, Router } from '@angular/router';
} from "@isa/oms/data-access";
import { ButtonComponent } from "@isa/ui/buttons";
import { NgIcon, provideIcons } from "@ng-icons/core";
import { Location } from "@angular/common";
import { isaActionChevronLeft } from "@isa/icons";
import { logger } from "@isa/core/logging";
import { ActivatedRoute, Router } from "@angular/router";
/**
* Main component for the return summary feature. Displays a list of items being returned
@@ -35,9 +35,9 @@ import { ActivatedRoute, Router } from '@angular/router';
* ```
*/
@Component({
selector: 'oms-feature-return-summary',
templateUrl: './return-summary.component.html',
styleUrls: ['./return-summary.component.scss'],
selector: "oms-feature-return-summary",
templateUrl: "./return-summary.component.html",
styleUrls: ["./return-summary.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReturnSummaryItemComponent, ButtonComponent, NgIcon],
providers: [provideIcons({ isaActionChevronLeft })],
@@ -74,9 +74,21 @@ export class ReturnSummaryComponent {
* - 'error': Operation failed
*/
returnItemsAndPrintReciptStatus = signal<
undefined | 'pending' | 'success' | 'error'
undefined | "pending" | "success" | "error"
>(undefined);
/**
* Computed signal to determine if the return items and print receipt operation is pending.
*
* This signal checks the current status of the returnItemsAndPrintReciptStatus signal
* and returns true if the status is 'pending', otherwise false.
*
* @returns {boolean} True if the operation is pending, false otherwise
*/
returnItemsAndPrintReciptStatusPending = computed(() => {
return this.returnItemsAndPrintReciptStatus() === "pending";
});
/**
* Handles the return and print process for multiple items.
*
@@ -93,35 +105,35 @@ export class ReturnSummaryComponent {
* @returns {Promise<void>}
*/
async returnItemsAndPrintRecipt() {
if (this.returnItemsAndPrintReciptStatus() === 'pending') {
this.#logger.warn('Return process already in progress', () => ({
function: 'returnItemsAndPrintRecipt',
if (this.returnItemsAndPrintReciptStatus() === "pending") {
this.#logger.warn("Return process already in progress", () => ({
function: "returnItemsAndPrintRecipt",
}));
return;
}
try {
this.returnItemsAndPrintReciptStatus.set('pending');
this.returnItemsAndPrintReciptStatus.set("pending");
const returnReceipts =
await this.#returnProcessService.completeReturnProcess(
this.returnProcesses(),
);
this.#logger.info('Return receipts created', () => ({
this.#logger.info("Return receipts created", () => ({
count: returnReceipts.length,
}));
this.returnItemsAndPrintReciptStatus.set('success');
this.returnItemsAndPrintReciptStatus.set("success");
this.#returnProcessStore.finishProcess(returnReceipts);
await this.#router.navigate(['../', 'review'], {
await this.#router.navigate(["../", "review"], {
relativeTo: this.#activatedRoute,
});
} catch (error) {
this.#logger.error('Error completing return process', error, () => ({
function: 'returnItemsAndPrintRecipt',
this.#logger.error("Error completing return process", error, () => ({
function: "returnItemsAndPrintRecipt",
}));
this.returnItemsAndPrintReciptStatus.set('error');
this.returnItemsAndPrintReciptStatus.set("error");
}
}
}