Merge branch 'develop' into feature/5202-Praemie

This commit is contained in:
Nino
2025-09-25 17:52:46 +02:00
58 changed files with 5889 additions and 1880 deletions

View File

@@ -159,12 +159,12 @@ const routes: Routes = [
import('@page/goods-in').then((m) => m.GoodsInModule),
canActivate: [CanActivateGoodsInGuard],
},
{
path: 'remission',
loadChildren: () =>
import('@page/remission').then((m) => m.PageRemissionModule),
canActivate: [CanActivateRemissionGuard],
},
// {
// path: 'remission',
// loadChildren: () =>
// import('@page/remission').then((m) => m.PageRemissionModule),
// canActivate: [CanActivateRemissionGuard],
// },
{
path: 'package-inspection',
loadChildren: () =>

View File

@@ -1,35 +1,34 @@
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { ActionReducer, MetaReducer, StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import packageInfo from 'packageJson';
import { environment } from '../environments/environment';
import { RootStateService } from './store/root-state.service';
import { rootReducer } from './store/root.reducer';
import { RootState } from './store/root.state';
export function storeInLocalStorage(reducer: ActionReducer<any>): ActionReducer<any> {
return function (state, action) {
if (action.type === 'HYDRATE') {
const initialState = RootStateService.LoadFromLocalStorage();
if (initialState?.version === packageInfo.version) {
return reducer(initialState, action);
}
}
return reducer(state, action);
};
}
export const metaReducers: MetaReducer<RootState>[] = !environment.production
? [storeInLocalStorage]
: [storeInLocalStorage];
@NgModule({
imports: [
StoreModule.forRoot(rootReducer, { metaReducers }),
EffectsModule.forRoot([]),
StoreDevtoolsModule.instrument({ name: 'ISA Ngrx Application Store', connectInZone: true }),
],
})
export class AppStoreModule {}
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { ActionReducer, MetaReducer, StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from '../environments/environment';
import { rootReducer } from './store/root.reducer';
import { RootState } from './store/root.state';
export function storeInLocalStorage(
reducer: ActionReducer<any>,
): ActionReducer<any> {
return function (state, action) {
if (action.type === 'HYDRATE') {
return reducer(action['payload'], action);
}
return reducer(state, action);
};
}
export const metaReducers: MetaReducer<RootState>[] = !environment.production
? [storeInLocalStorage]
: [storeInLocalStorage];
@NgModule({
imports: [
StoreModule.forRoot(rootReducer, { metaReducers }),
EffectsModule.forRoot([]),
StoreDevtoolsModule.instrument({
name: 'ISA Ngrx Application Store',
connectInZone: true,
}),
],
})
export class AppStoreModule {}

View File

@@ -1,3 +1,4 @@
import { version } from '../../../../package.json';
import {
HTTP_INTERCEPTORS,
provideHttpClient,
@@ -53,7 +54,6 @@ import {
ScanAdapterService,
ScanditScanAdapterModule,
} from '@adapter/scan';
import { RootStateService } from './store/root-state.service';
import * as Commands from './commands';
import { PreviewComponent } from './preview';
import { NativeContainerService } from '@external/native-container';
@@ -67,7 +67,7 @@ import {
matWifiOff,
} from '@ng-icons/material-icons/baseline';
import { NetworkStatusService } from './services/network-status.service';
import { firstValueFrom } from 'rxjs';
import { debounceTime, firstValueFrom } from 'rxjs';
import { provideMatomo } from 'ngx-matomo-client';
import { withRouter, withRouteData } from 'ngx-matomo-client';
import {
@@ -78,6 +78,7 @@ import {
ConsoleLogSink,
} from '@isa/core/logging';
import { IDBStorageProvider, UserStorageProvider } from '@isa/core/storage';
import { Store } from '@ngrx/store';
registerLocaleData(localeDe, localeDeExtra);
registerLocaleData(localeDe, 'de', localeDeExtra);
@@ -117,10 +118,6 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
await strategy.login();
}
statusElement.innerHTML = 'App wird initialisiert...';
const state = injector.get(RootStateService);
await state.init();
statusElement.innerHTML = 'Native Container wird initialisiert...';
const nativeContainer = injector.get(NativeContainerService);
await nativeContainer.init();
@@ -129,8 +126,21 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
await injector.get(IDBStorageProvider).init();
statusElement.innerHTML = 'Benutzerzustand wird geladen...';
await injector.get(UserStorageProvider).init();
const userStorage = injector.get(UserStorageProvider);
await userStorage.init();
const store = injector.get(Store);
// Hydrate Ngrx Store
const state = userStorage.get('store');
if (state && state['version'] === version) {
store.dispatch({ type: 'HYDRATE', payload: userStorage.get('store') });
}
// Subscribe on Store changes and save to user storage
store.pipe(debounceTime(1000)).subscribe((state) => {
userStorage.set('store', state);
});
} catch (error) {
console.error('Error during app initialization', error);
laoderElement.remove();
statusElement.classList.add('text-xl');
statusElement.innerHTML +=

View File

@@ -9,6 +9,9 @@ import {
} from "@ui/modal";
import { IsaLogProvider } from "./isa.log-provider";
import { LogLevel } from "@core/logger";
import { ZodError } from "zod";
import { extractZodErrorMessage } from "@isa/common/data-access";
import { firstValueFrom } from "rxjs";
@Injectable({ providedIn: "root" })
export class IsaErrorHandler implements ErrorHandler {
@@ -28,7 +31,7 @@ export class IsaErrorHandler implements ErrorHandler {
}
if (error instanceof HttpErrorResponse && error?.status === 401) {
await this._modal
await firstValueFrom(this._modal
.open({
content: UiDialogModalComponent,
title: "Sitzung abgelaufen",
@@ -41,12 +44,33 @@ export class IsaErrorHandler implements ErrorHandler {
],
} as DialogModel,
})
.afterClosed$.toPromise();
.afterClosed$);
this._authService.logout();
return;
}
// Handle Zod validation errors
if (error instanceof ZodError) {
const zodErrorMessage = extractZodErrorMessage(error);
await firstValueFrom(this._modal
.open({
content: UiDialogModalComponent,
title: "Validierungsfehler",
data: {
handleCommand: false,
content: `Die eingegebenen Daten sind ungültig:\n\n${zodErrorMessage}`,
actions: [
{ command: "CLOSE", selected: true, label: "OK" },
],
} as DialogModel,
})
.afterClosed$);
return;
}
try {
this._isaLogProvider.log(LogLevel.ERROR, "Client Error", error);
} catch (logError) {

View File

@@ -1,124 +0,0 @@
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' })
export class RootStateService {
static LOCAL_STORAGE_KEY = 'ISA_APP_INITIALSTATE';
#storage = injectStorage(UserStorageProvider);
private _cancelSave = new Subject<void>();
constructor(
private readonly _authService: AuthService,
private _logger: Logger,
private _store: Store,
) {
if (!environment.production) {
console.log(
'Die UserState kann in der Konsole mit der Funktion "clearUserState()" geleert werden.',
);
}
window['clearUserState'] = () => {
this.clear();
};
}
async init() {
await this.load();
this._store.dispatch({
type: 'HYDRATE',
payload: RootStateService.LoadFromLocalStorage(),
});
this.initSave();
}
initSave() {
this._store
.select((state) => state)
.pipe(takeUntil(this._cancelSave), debounceTime(1000))
.subscribe((state) => {
const data = {
...state,
version: packageInfo.version,
sub: this._authService.getClaimByKey('sub'),
};
RootStateService.SaveToLocalStorageRaw(JSON.stringify(data));
return this.#storage.set('state', data);
});
}
/**
* Loads the initial state from local storage and returns true/false if state was changed
*/
async load(): Promise<boolean> {
try {
const res = await this.#storage.get('state');
const storageContent = RootStateService.LoadFromLocalStorageRaw();
if (res) {
RootStateService.SaveToLocalStorageRaw(JSON.stringify(res));
}
if (!isEqual(res, storageContent)) {
return true;
}
} catch (error) {
this._logger.log(LogLevel.ERROR, error);
}
return false;
}
async clear() {
try {
this._cancelSave.next();
await this.#storage.clear('state');
await new Promise((resolve) => setTimeout(resolve, 100));
RootStateService.RemoveFromLocalStorage();
await new Promise((resolve) => setTimeout(resolve, 100));
window.location.reload();
} catch (error) {
this._logger.log(LogLevel.ERROR, error);
}
}
static SaveToLocalStorage(state: RootState) {
RootStateService.SaveToLocalStorageRaw(JSON.stringify(state));
}
static SaveToLocalStorageRaw(state: string) {
localStorage.setItem(RootStateService.LOCAL_STORAGE_KEY, state);
}
static LoadFromLocalStorage(): RootState {
const raw = RootStateService.LoadFromLocalStorageRaw();
if (raw) {
try {
return JSON.parse(raw);
} catch (error) {
console.error('Error parsing local storage:', error);
this.RemoveFromLocalStorage();
}
}
return undefined;
}
static LoadFromLocalStorageRaw(): string {
return localStorage.getItem(RootStateService.LOCAL_STORAGE_KEY);
}
static RemoveFromLocalStorage() {
localStorage.removeItem(RootStateService.LOCAL_STORAGE_KEY);
}
}

View File

@@ -1,10 +1,18 @@
<div class="notification-list scroll-bar">
@for (notification of notifications; track notification) {
<modal-notifications-list-item [item]="notification" (itemSelected)="itemSelected($event)"></modal-notifications-list-item>
<modal-notifications-list-item
[item]="notification"
(itemSelected)="itemSelected($event)"
></modal-notifications-list-item>
<hr />
}
</div>
<div class="actions">
<a class="cta-primary" [routerLink]="['/filiale/remission/create']" (click)="navigated.emit()">Zur Remission</a>
<a
class="cta-primary"
[routerLink]="remissionPath()"
(click)="navigated.emit()"
>Zur Remission</a
>
</div>

View File

@@ -1,35 +1,55 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { Router } from '@angular/router';
import { PickupShelfInNavigationService } from '@shared/services/navigation';
import { UiFilter } from '@ui/filter';
import { MessageBoardItemDTO } from '@hub/notifications';
@Component({
selector: 'modal-notifications-remission-group',
templateUrl: 'notifications-remission-group.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ModalNotificationsRemissionGroupComponent {
private _pickupShelfInNavigationService = inject(PickupShelfInNavigationService);
@Input()
notifications: MessageBoardItemDTO[];
@Output()
navigated = new EventEmitter<void>();
constructor(private _router: Router) {}
itemSelected(item: MessageBoardItemDTO) {
const defaultNav = this._pickupShelfInNavigationService.listRoute();
const queryParams = UiFilter.getQueryParamsFromQueryTokenDTO(item.queryToken);
this._router.navigate(defaultNav.path, {
queryParams: {
...defaultNav.queryParams,
...queryParams,
},
});
this.navigated.emit();
}
}
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
inject,
linkedSignal,
} from '@angular/core';
import { Router } from '@angular/router';
import { PickupShelfInNavigationService } from '@shared/services/navigation';
import { UiFilter } from '@ui/filter';
import { MessageBoardItemDTO } from '@hub/notifications';
import { TabService } from '@isa/core/tabs';
@Component({
selector: 'modal-notifications-remission-group',
templateUrl: 'notifications-remission-group.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ModalNotificationsRemissionGroupComponent {
tabService = inject(TabService);
private _pickupShelfInNavigationService = inject(
PickupShelfInNavigationService,
);
@Input()
notifications: MessageBoardItemDTO[];
@Output()
navigated = new EventEmitter<void>();
remissionPath = linkedSignal(() => [
'/',
this.tabService.activatedTab()?.id || Date.now(),
'remission',
]);
constructor(private _router: Router) {}
itemSelected(item: MessageBoardItemDTO) {
const defaultNav = this._pickupShelfInNavigationService.listRoute();
const queryParams = UiFilter.getQueryParamsFromQueryTokenDTO(
item.queryToken,
);
this._router.navigate(defaultNav.path, {
queryParams: {
...defaultNav.queryParams,
...queryParams,
},
});
this.navigated.emit();
}
}

View File

@@ -16,13 +16,34 @@
[deltaEnd]="150"
[itemLength]="itemLength$ | async"
[containerHeight]="24.5"
>
@for (bueryNumberGroup of items$ | async | groupBy: byBuyerNumberFn; track bueryNumberGroup) {
>
@for (
bueryNumberGroup of items$ | async | groupBy: byBuyerNumberFn;
track bueryNumberGroup
) {
<shared-goods-in-out-order-group>
@for (orderNumberGroup of bueryNumberGroup.items | groupBy: byOrderNumberFn; track orderNumberGroup; let lastOrderNumber = $last) {
@for (processingStatusGroup of orderNumberGroup.items | groupBy: byProcessingStatusFn; track processingStatusGroup; let lastProcessingStatus = $last) {
@for (compartmentCodeGroup of processingStatusGroup.items | groupBy: byCompartmentCodeFn; track compartmentCodeGroup; let lastCompartmentCode = $last) {
@for (item of compartmentCodeGroup.items; track item; let firstItem = $first) {
@for (
orderNumberGroup of bueryNumberGroup.items | groupBy: byOrderNumberFn;
track orderNumberGroup;
let lastOrderNumber = $last
) {
@for (
processingStatusGroup of orderNumberGroup.items
| groupBy: byProcessingStatusFn;
track processingStatusGroup;
let lastProcessingStatus = $last
) {
@for (
compartmentCodeGroup of processingStatusGroup.items
| groupBy: byCompartmentCodeFn;
track compartmentCodeGroup;
let lastCompartmentCode = $last
) {
@for (
item of compartmentCodeGroup.items;
track item;
let firstItem = $first
) {
<shared-goods-in-out-order-group-item
[item]="item"
[showCompartmentCode]="firstItem"
@@ -49,7 +70,6 @@
<div class="empty-message">Es sind im Moment keine Artikel vorhanden</div>
}
<div class="actions">
@if (actions$ | async; as actions) {
@for (action of actions; track action) {
@@ -57,19 +77,27 @@
[disabled]="(changeActionLoader$ | async) || (loading$ | async)"
class="cta-action cta-action-primary"
(click)="handleAction(action)"
>
<ui-spinner
[show]="(changeActionLoader$ | async) || (loading$ | async)"
>{{ action.label }}</ui-spinner
>
<ui-spinner [show]="(changeActionLoader$ | async) || (loading$ | async)">{{ action.label }}</ui-spinner>
</button>
}
}
@if (listEmpty$ | async) {
<a class="cta-action cta-action-secondary" [routerLink]="['/filiale', 'goods', 'in']">
<a
class="cta-action cta-action-secondary"
[routerLink]="['/filiale', 'goods', 'in']"
>
Zur Bestellpostensuche
</a>
}
@if (listEmpty$ | async) {
<a class="cta-action cta-action-primary" [routerLink]="['/filiale', 'remission']">Zur Remission</a>
<a class="cta-action cta-action-primary" [routerLink]="remissionPath()"
>Zur Remission</a
>
}
</div>

View File

@@ -1,200 +1,254 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { Router } from '@angular/router';
import { BreadcrumbService } from '@core/breadcrumb';
import { KeyValueDTOOfStringAndString, OrderItemListItemDTO } from '@generated/swagger/oms-api';
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
import { UiScrollContainerComponent } from '@ui/scroll-container';
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
import { first, map, shareReplay, takeUntil } from 'rxjs/operators';
import { GoodsInRemissionPreviewStore } from './goods-in-remission-preview.store';
import { Config } from '@core/config';
import { ToasterService } from '@shared/shell';
import { PickupShelfInNavigationService } from '@shared/services/navigation';
import { CacheService } from '@core/cache';
@Component({
selector: 'page-goods-in-remission-preview',
templateUrl: 'goods-in-remission-preview.component.html',
styleUrls: ['goods-in-remission-preview.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [GoodsInRemissionPreviewStore],
standalone: false,
})
export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
private _pickupShelfInNavigationService = inject(PickupShelfInNavigationService);
@ViewChild(UiScrollContainerComponent) scrollContainer: UiScrollContainerComponent;
items$ = this._store.results$;
itemLength$ = this.items$.pipe(map((items) => items?.length));
hits$ = this._store.hits$;
loading$ = this._store.fetching$.pipe(shareReplay());
changeActionLoader$ = new BehaviorSubject<boolean>(false);
listEmpty$ = combineLatest([this.loading$, this.hits$]).pipe(
map(([loading, hits]) => !loading && hits === 0),
shareReplay(),
);
actions$ = this.items$.pipe(map((items) => items[0]?.actions));
private _onDestroy$ = new Subject<void>();
byBuyerNumberFn = (item: OrderItemListItemDTO) => item.buyerNumber;
byOrderNumberFn = (item: OrderItemListItemDTO) => item.orderNumber;
byProcessingStatusFn = (item: OrderItemListItemDTO) => item.processingStatus;
byCompartmentCodeFn = (item: OrderItemListItemDTO) =>
item.compartmentInfo ? `${item.compartmentCode}_${item.compartmentInfo}` : item.compartmentCode;
private readonly SCROLL_POSITION_TOKEN = 'REMISSION_PREVIEW_SCROLL_POSITION';
constructor(
private _breadcrumb: BreadcrumbService,
private _store: GoodsInRemissionPreviewStore,
private _router: Router,
private _modal: UiModalService,
private _config: Config,
private _toast: ToasterService,
private _cache: CacheService,
) {}
ngOnInit(): void {
this.initInitialSearch();
this.createBreadcrumb();
this.removeBreadcrumbs();
}
ngOnDestroy(): void {
this._onDestroy$.next();
this._onDestroy$.complete();
this._addScrollPositionToCache();
this.updateBreadcrumb();
}
private _removeScrollPositionFromCache(): void {
this._cache.delete({ processId: this._config.get('process.ids.goodsIn'), token: this.SCROLL_POSITION_TOKEN });
}
private _addScrollPositionToCache(): void {
this._cache.set<number>(
{ processId: this._config.get('process.ids.goodsIn'), token: this.SCROLL_POSITION_TOKEN },
this.scrollContainer?.scrollPos,
);
}
private async _getScrollPositionFromCache(): Promise<number> {
return await this._cache.get<number>({
processId: this._config.get('process.ids.goodsIn'),
token: this.SCROLL_POSITION_TOKEN,
});
}
async createBreadcrumb() {
await this._breadcrumb.addOrUpdateBreadcrumbIfNotExists({
key: this._config.get('process.ids.goodsIn'),
name: 'Abholfachremissionsvorschau',
path: '/filiale/goods/in/preview',
section: 'branch',
params: { view: 'remission' },
tags: ['goods-in', 'preview'],
});
}
async updateBreadcrumb() {
const crumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'preview'])
.pipe(first())
.toPromise();
for (const crumb of crumbs) {
this._breadcrumb.patchBreadcrumb(crumb.id, {
name: crumb.name,
});
}
}
async removeBreadcrumbs() {
let breadcrumbsToDelete = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in'])
.pipe(first())
.toPromise();
breadcrumbsToDelete = breadcrumbsToDelete.filter(
(crumb) => !crumb.tags.includes('preview') && !crumb.tags.includes('main'),
);
breadcrumbsToDelete.forEach((crumb) => {
this._breadcrumb.removeBreadcrumb(crumb.id, true);
});
const detailsCrumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'details'])
.pipe(first())
.toPromise();
const editCrumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'edit'])
.pipe(first())
.toPromise();
detailsCrumbs.forEach((crumb) => {
this._breadcrumb.removeBreadcrumb(crumb.id, true);
});
editCrumbs.forEach((crumb) => {
this._breadcrumb.removeBreadcrumb(crumb.id, true);
});
}
initInitialSearch() {
if (this._store.hits === 0) {
this._store.searchResult$.pipe(takeUntil(this._onDestroy$)).subscribe(async (result) => {
await this.createBreadcrumb();
this.scrollContainer?.scrollTo((await this._getScrollPositionFromCache()) ?? 0);
this._removeScrollPositionFromCache();
});
}
this._store.search();
}
async navigateToRemission() {
await this._router.navigate(['/filiale/remission']);
}
navigateToDetails(orderItem: OrderItemListItemDTO) {
const nav = this._pickupShelfInNavigationService.detailRoute({ item: orderItem, side: false });
this._router.navigate(nav.path, { queryParams: { ...nav.queryParams, view: 'remission' } });
}
async handleAction(action: KeyValueDTOOfStringAndString) {
this.changeActionLoader$.next(true);
try {
const response = await this._store.createRemissionFromPreview().pipe(first()).toPromise();
if (!response?.dialog) {
this._toast.open({
title: 'Abholfachremission',
message: response?.message,
});
}
await this.navigateToRemission();
} catch (error) {
this._modal.open({
content: UiErrorModalComponent,
data: error,
});
console.error(error);
}
this.changeActionLoader$.next(false);
}
}
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
ViewChild,
inject,
linkedSignal,
} from '@angular/core';
import { Router } from '@angular/router';
import { BreadcrumbService } from '@core/breadcrumb';
import {
KeyValueDTOOfStringAndString,
OrderItemListItemDTO,
} from '@generated/swagger/oms-api';
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
import { UiScrollContainerComponent } from '@ui/scroll-container';
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
import { first, map, shareReplay, takeUntil } from 'rxjs/operators';
import { GoodsInRemissionPreviewStore } from './goods-in-remission-preview.store';
import { Config } from '@core/config';
import { ToasterService } from '@shared/shell';
import { PickupShelfInNavigationService } from '@shared/services/navigation';
import { CacheService } from '@core/cache';
import { TabService } from '@isa/core/tabs';
@Component({
selector: 'page-goods-in-remission-preview',
templateUrl: 'goods-in-remission-preview.component.html',
styleUrls: ['goods-in-remission-preview.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [GoodsInRemissionPreviewStore],
standalone: false,
})
export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
tabService = inject(TabService);
private _pickupShelfInNavigationService = inject(
PickupShelfInNavigationService,
);
@ViewChild(UiScrollContainerComponent)
scrollContainer: UiScrollContainerComponent;
items$ = this._store.results$;
itemLength$ = this.items$.pipe(map((items) => items?.length));
hits$ = this._store.hits$;
loading$ = this._store.fetching$.pipe(shareReplay());
changeActionLoader$ = new BehaviorSubject<boolean>(false);
listEmpty$ = combineLatest([this.loading$, this.hits$]).pipe(
map(([loading, hits]) => !loading && hits === 0),
shareReplay(),
);
actions$ = this.items$.pipe(map((items) => items[0]?.actions));
private _onDestroy$ = new Subject<void>();
byBuyerNumberFn = (item: OrderItemListItemDTO) => item.buyerNumber;
byOrderNumberFn = (item: OrderItemListItemDTO) => item.orderNumber;
byProcessingStatusFn = (item: OrderItemListItemDTO) => item.processingStatus;
byCompartmentCodeFn = (item: OrderItemListItemDTO) =>
item.compartmentInfo
? `${item.compartmentCode}_${item.compartmentInfo}`
: item.compartmentCode;
private readonly SCROLL_POSITION_TOKEN = 'REMISSION_PREVIEW_SCROLL_POSITION';
remissionPath = linkedSignal(() => [
'/',
this.tabService.activatedTab()?.id || Date.now(),
'remission',
]);
constructor(
private _breadcrumb: BreadcrumbService,
private _store: GoodsInRemissionPreviewStore,
private _router: Router,
private _modal: UiModalService,
private _config: Config,
private _toast: ToasterService,
private _cache: CacheService,
) {}
ngOnInit(): void {
this.initInitialSearch();
this.createBreadcrumb();
this.removeBreadcrumbs();
}
ngOnDestroy(): void {
this._onDestroy$.next();
this._onDestroy$.complete();
this._addScrollPositionToCache();
this.updateBreadcrumb();
}
private _removeScrollPositionFromCache(): void {
this._cache.delete({
processId: this._config.get('process.ids.goodsIn'),
token: this.SCROLL_POSITION_TOKEN,
});
}
private _addScrollPositionToCache(): void {
this._cache.set<number>(
{
processId: this._config.get('process.ids.goodsIn'),
token: this.SCROLL_POSITION_TOKEN,
},
this.scrollContainer?.scrollPos,
);
}
private async _getScrollPositionFromCache(): Promise<number> {
return await this._cache.get<number>({
processId: this._config.get('process.ids.goodsIn'),
token: this.SCROLL_POSITION_TOKEN,
});
}
async createBreadcrumb() {
await this._breadcrumb.addOrUpdateBreadcrumbIfNotExists({
key: this._config.get('process.ids.goodsIn'),
name: 'Abholfachremissionsvorschau',
path: '/filiale/goods/in/preview',
section: 'branch',
params: { view: 'remission' },
tags: ['goods-in', 'preview'],
});
}
async updateBreadcrumb() {
const crumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
'preview',
])
.pipe(first())
.toPromise();
for (const crumb of crumbs) {
this._breadcrumb.patchBreadcrumb(crumb.id, {
name: crumb.name,
});
}
}
async removeBreadcrumbs() {
let breadcrumbsToDelete = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
])
.pipe(first())
.toPromise();
breadcrumbsToDelete = breadcrumbsToDelete.filter(
(crumb) =>
!crumb.tags.includes('preview') && !crumb.tags.includes('main'),
);
breadcrumbsToDelete.forEach((crumb) => {
this._breadcrumb.removeBreadcrumb(crumb.id, true);
});
const detailsCrumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
'details',
])
.pipe(first())
.toPromise();
const editCrumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
'edit',
])
.pipe(first())
.toPromise();
detailsCrumbs.forEach((crumb) => {
this._breadcrumb.removeBreadcrumb(crumb.id, true);
});
editCrumbs.forEach((crumb) => {
this._breadcrumb.removeBreadcrumb(crumb.id, true);
});
}
initInitialSearch() {
if (this._store.hits === 0) {
this._store.searchResult$
.pipe(takeUntil(this._onDestroy$))
.subscribe(async (result) => {
await this.createBreadcrumb();
this.scrollContainer?.scrollTo(
(await this._getScrollPositionFromCache()) ?? 0,
);
this._removeScrollPositionFromCache();
});
}
this._store.search();
}
async navigateToRemission() {
await this._router.navigate(this.remissionPath());
}
navigateToDetails(orderItem: OrderItemListItemDTO) {
const nav = this._pickupShelfInNavigationService.detailRoute({
item: orderItem,
side: false,
});
this._router.navigate(nav.path, {
queryParams: { ...nav.queryParams, view: 'remission' },
});
}
async handleAction(action: KeyValueDTOOfStringAndString) {
this.changeActionLoader$.next(true);
try {
const response = await this._store
.createRemissionFromPreview()
.pipe(first())
.toPromise();
if (!response?.dialog) {
this._toast.open({
title: 'Abholfachremission',
message: response?.message,
});
}
await this.navigateToRemission();
} catch (error) {
this._modal.open({
content: UiErrorModalComponent,
data: error,
});
console.error(error);
}
this.changeActionLoader$.next(false);
}
}

View File

@@ -268,35 +268,6 @@
</div>
</div>
@if (remissionNavigation$ | async; as remissionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="remissionNavigation.path"
[queryParams]="remissionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="assignment-return"></shared-icon>
</span>
<span class="side-menu-group-item-label">Remission</span>
</a>
}
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); fetchAndOpenPackages()"
[routerLink]="packageInspectionNavigation.path"
[queryParams]="packageInspectionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="clipboard-check-outline"></shared-icon>
</span>
<span class="side-menu-group-item-label">Wareneingang</span>
</a>
}
<div class="side-menu-group-sub-item-wrapper">
<a
class="side-menu-group-item"
@@ -353,5 +324,20 @@
</div>
}
</div>
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); fetchAndOpenPackages()"
[routerLink]="packageInspectionNavigation.path"
[queryParams]="packageInspectionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="clipboard-check-outline"></shared-icon>
</span>
<span class="side-menu-group-item-label">Wareneingang</span>
</a>
}
</nav>
</div>

View File

@@ -221,14 +221,6 @@ export class ShellSideMenuComponent {
// this._pickUpShelfInNavigation.listRoute()
// );
remissionNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.remission'),
{
path: ['/filiale', 'remission'],
queryParams: {},
},
);
packageInspectionNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.packageInspection'),
{

View File

@@ -12,7 +12,7 @@ variables:
value: '4'
# Minor Version einstellen
- name: 'Minor'
value: '0'
value: '1'
- name: 'Patch'
value: "$[counter(format('{0}.{1}', variables['Major'], variables['Minor']),0)]"
- name: 'BuildUniqueID'

200
docs/architecture/README.md Normal file
View File

@@ -0,0 +1,200 @@
# Architecture Decision Records (ADRs)
## Overview
Architecture Decision Records (ADRs) are lightweight documents that capture important architectural decisions made during the development of the ISA-Frontend project. They provide context for why certain decisions were made, helping current and future team members understand the reasoning behind architectural choices.
## What are ADRs?
An Architecture Decision Record is a document that captures a single architectural decision and its rationale. The goal of an ADR is to document the architectural decisions that are being made so that:
- **Future team members** can understand why certain decisions were made
- **Current team members** can refer back to the reasoning behind decisions
- **Architectural evolution** can be tracked over time
- **Knowledge transfer** is facilitated during team changes
## ADR Structure
Each ADR follows a consistent structure based on our [TEMPLATE.md](./TEMPLATE.md) and includes:
- **Problem Statement**: What architectural challenge needs to be addressed
- **Decision**: The architectural decision made
- **Rationale**: Why this decision was chosen
- **Consequences**: Both positive and negative outcomes of the decision
- **Alternatives**: Other options that were considered
- **Implementation**: Technical details and examples
- **Status**: Current state of the decision (Draft, Approved, Superseded, etc.)
## Naming Convention
ADRs should follow this naming pattern:
```
NNNN-short-descriptive-title.md
```
Where:
- `NNNN` is a 4-digit sequential number (e.g., 0001, 0002, 0003...)
- `short-descriptive-title` uses kebab-case and briefly describes the decision
- `.md` indicates it's a Markdown file
### Examples:
- `0001-use-standalone-components.md`
- `0002-adopt-ngrx-signals.md`
- `0003-implement-micro-frontend-architecture.md`
- `0004-choose-vitest-over-jest.md`
## Process Guidelines
### 1. When to Create an ADR
Create an ADR when making decisions about:
- **Architecture patterns** (e.g., micro-frontends, monorepo structure)
- **Technology choices** (e.g., testing frameworks, state management)
- **Development practices** (e.g., code organization, build processes)
- **Technical standards** (e.g., coding conventions, performance requirements)
- **Infrastructure decisions** (e.g., deployment strategies, CI/CD processes)
### 2. ADR Lifecycle
```
Draft → Under Review → Approved → [Superseded/Deprecated]
```
- **Draft**: Initial version, being written
- **Under Review**: Shared with team for feedback and discussion
- **Approved**: Team has agreed and decision is implemented
- **Superseded**: Replaced by a newer ADR
- **Deprecated**: No longer applicable but kept for historical reference
### 3. Creation Process
1. **Identify the Need**: Recognize an architectural decision needs documentation
2. **Create from Template**: Copy [TEMPLATE.md](./TEMPLATE.md) to create new ADR
3. **Fill in Content**: Complete all sections with relevant information
4. **Set Status to Draft**: Mark the document as "Draft" initially
5. **Share for Review**: Present to team for discussion and feedback
6. **Iterate**: Update based on team input
7. **Approve**: Once consensus is reached, mark as "Approved"
8. **Implement**: Begin implementation of the architectural decision
### 4. Review Process
- **Author Review**: Self-review for completeness and clarity
- **Peer Review**: Share with relevant team members for technical review
- **Architecture Review**: Present in architecture meetings if significant
- **Final Approval**: Get sign-off from technical leads/architects
## Angular/Nx Specific Considerations
When writing ADRs for this project, consider these Angular/Nx specific aspects:
### Architecture Decisions
- **Library organization** in the monorepo structure
- **Dependency management** between applications and libraries
- **Feature module vs. standalone component** approaches
- **State management patterns** (NgRx, Signals, Services)
- **Routing strategies** for large applications
### Technical Decisions
- **Build optimization** strategies using Nx
- **Testing approaches** for different types of libraries
- **Code sharing patterns** across applications
- **Performance optimization** techniques
- **Bundle splitting** and lazy loading strategies
### Development Workflow
- **Nx executor usage** for custom tasks
- **Generator patterns** for code scaffolding
- **Linting and formatting** configurations
- **CI/CD pipeline** optimizations using Nx affected commands
## Template Usage
### Getting Started
1. Copy the [TEMPLATE.md](./TEMPLATE.md) file
2. Rename it following the naming convention
3. Replace placeholder text with actual content
4. Focus on the "why" not just the "what"
5. Include concrete examples and code snippets
6. Consider both immediate and long-term consequences
### Key Template Sections
- **Decision**: State the architectural decision clearly and concisely
- **Context**: Provide background information and constraints
- **Consequences**: Be honest about both benefits and drawbacks
- **Implementation**: Include practical examples relevant to Angular/Nx
- **Alternatives**: Show you considered other options
## Examples of Good ADRs
Here are some example titles that would make good ADRs for this project:
- **State Management**: "0001-adopt-ngrx-signals-for-component-state.md"
- **Testing Strategy**: "0002-use-angular-testing-utilities-over-spectator.md"
- **Code Organization**: "0003-implement-domain-driven-library-structure.md"
- **Performance**: "0004-implement-lazy-loading-for-feature-modules.md"
- **Build Process**: "0005-use-nx-cloud-for-distributed-task-execution.md"
## Best Practices
### Writing Effective ADRs
1. **Be Concise**: Keep it focused and to the point
2. **Be Specific**: Include concrete examples and implementation details
3. **Be Honest**: Document both pros and cons honestly
4. **Be Timely**: Write ADRs close to when decisions are made
5. **Be Collaborative**: Involve relevant team members in the process
### Maintenance
- **Review Regularly**: Check ADRs during architecture reviews
- **Update Status**: Keep status current as decisions evolve
- **Link Related ADRs**: Reference connected decisions
- **Archive Outdated**: Mark superseded ADRs appropriately
### Code Examples
When including code examples:
- Use actual project syntax and patterns
- Include both TypeScript and template examples where relevant
- Show before/after scenarios for changes
- Reference specific files in the codebase when possible
## Tools and Integration
### Recommended Tools
- **Markdown Editor**: Use any markdown-capable editor
- **Version Control**: All ADRs are tracked in Git
- **Review Process**: Use PR reviews for ADR approval
- **Documentation**: Link ADRs from relevant code comments
### Integration with Development
- Reference ADR numbers in commit messages when implementing decisions
- Include ADR links in PR descriptions for architectural changes
- Update ADRs when decisions need modification
- Use ADRs as reference during code reviews
## Getting Help
### Questions or Issues?
- **Team Discussions**: Bring up in team meetings or Slack
- **Architecture Review**: Present in architecture meetings
- **Documentation**: Update this README if process improvements are needed
### Resources
- [Architecture Decision Records (ADRs) - Michael Nygard](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)
- [ADR GitHub Organization](https://adr.github.io/)
- [Nx Documentation](https://nx.dev/getting-started/intro)
- [Angular Architecture Guide](https://angular.dev/guide/architecture)
---
*This ADR system helps maintain architectural consistency and knowledge sharing across the ISA-Frontend project. Keep it updated and use it regularly for the best results.*

View File

@@ -0,0 +1,138 @@
# ADR NNNN: <short-descriptive-title>
| Field | Value |
|-------|-------|
| Status | Draft / Under Review / Approved / Superseded by ADR NNNN / Deprecated |
| Date | YYYY-MM-DD |
| Owners | <author(s)> |
| Participants | <key reviewers / stakeholders> |
| Related ADRs | NNNN (title), NNNN (title) |
| Tags | architecture, <domain>, <category> |
---
## Summary (Decision in One Sentence)
Concise statement of the architectural decision. Avoid rationale here—just the what.
## Context & Problem Statement
Describe the background and the problem this decision addresses.
- Business drivers / user needs
- Technical constraints (performance, security, scalability, compliance, legacy, regulatory)
- Current pain points / gaps
- Measurable goals / success criteria (e.g. reduce build time by 30%)
### Scope
What is in scope and explicitly out of scope for this decision.
## Decision
State the decision clearly (active voice). Include high-level approach or pattern selection, not implementation detail.
## Rationale
Why this option was selected:
- Alignment with strategic/technical direction
- Trade-offs considered
- Data, benchmarks, experiments, spikes
- Impact on developer experience / velocity
- Long-term maintainability & extensibility
## Alternatives Considered
| Alternative | Summary | Pros | Cons | Reason Not Chosen |
|-------------|---------|------|------|-------------------|
| Option A | | | | |
| Option B | | | | |
| Option C | | | | |
Add deeper detail below if needed:
### Option A <name>
### Option B <name>
### Option C <name>
## Consequences
### Positive
-
### Negative / Risks / Debt Introduced
-
### Neutral / Open Questions
-
## Implementation Plan
High-level rollout strategy. Break into phases if applicable.
1. Phase 0 Spike / Validation
2. Phase 1 Foundation / Infrastructure
3. Phase 2 Incremental Adoption / Migration
4. Phase 3 Hardening / Optimization
5. Phase 4 Decommission Legacy
### Tasks / Workstreams
- Infra / tooling changes
- Library additions (Nx generators, new libs under `libs/<domain>`)
- Refactors / migrations
- Testing strategy updates (Jest → Vitest, Signals adoption, etc.)
- Documentation & onboarding materials
### Acceptance Criteria
List objective criteria to mark implementation complete.
### Rollback Plan
How to revert safely if outcomes are negative.
## Architectural Impact
### Nx / Monorepo Layout
Describe changes to library boundaries, tags, dependency graph, affected projects.
### Module / Library Design
New or modified public APIs (`src/index.ts` changes, path aliases additions to `tsconfig.base.json`).
### State Management
Implications for Signals, NgRx, resource factories, persistence patterns (`withStorage`).
### Runtime & Performance
Bundle size, lazy loading, code splitting, caching, SSR/hydration considerations.
### Security & Compliance
AuthZ/AuthN, token handling, data residency, PII, secure storage.
### Observability & Logging
Logging contexts (`@isa/core/logging`), metrics, tracing hooks.
### DX / Tooling
Generators, lint rules, schematic updates, local dev flow.
## Detailed Design Elements
(Optional deeper technical articulation.)
- Sequence diagrams / component diagrams
- Data flow / async flow
- Error handling strategy
- Concurrency / cancellation (e.g. `rxMethod` + `switchMap` usage)
- Abstractions & extension points
## Code Examples
### Before
```ts
// Previous approach (simplified)
```
### After
```ts
// New approach (simplified)
```
### Migration Snippet
```ts
// Example incremental migration pattern
```
## Open Questions / Follow-Ups
- Unresolved design clarifications
- Dependent ADRs required
- External approvals needed
## Decision Review & Revalidation
When and how this ADR will be re-evaluated (date, trigger conditions, metrics thresholds).
## Status Log
| Date | Change | Author |
|------|--------|--------|
| YYYY-MM-DD | Created (Draft) | |
| YYYY-MM-DD | Approved | |
| YYYY-MM-DD | Superseded by ADR NNNN | |
## References
- Links to spike notes, benchmark results
- External articles, standards, RFCs
- Related code PRs / commits
---
> Document updates MUST reference this ADR number in commit messages: `ADR-NNNN:` prefix.
> Keep this document updated through all lifecycle stages.

View File

@@ -0,0 +1,506 @@
# ADR 0001: Implement `data-access` API Requests
| Field | Value |
|-------|-------|
| Status | Draft |
| Date | 29.09.2025 |
| Owners | Lorenz, Nino |
| Participants | N/A |
| Related ADRs | N/A |
| Tags | architecture, data-access, library, swagger |
---
## Summary (Decision in One Sentence)
Standardize data-access library implementation patterns for API requests using Zod schemas for validation, domain-specific models extending generated DTOs, and service layers that integrate with generated Swagger clients.
## Context & Problem Statement
The ISA Frontend application requires consistent and maintainable patterns for implementing API requests across multiple domain libraries. Current data-access libraries show varying implementation approaches that need standardization.
**Business drivers / user needs:**
- Consistent error handling across all API interactions
- Type-safe request/response handling to prevent runtime errors
- Maintainable code structure for easy onboarding and development
- Reliable validation of API inputs and outputs
**Technical constraints:**
- Must integrate with generated Swagger clients (`@generated/swagger/*`)
- Need to support abort signals for request cancellation
- Require caching and performance optimization capabilities
- Must align with existing logging infrastructure (`@isa/core/logging`)
- Support for domain-specific model extensions beyond generated DTOs
**Current pain points:**
- Inconsistent validation patterns across different data-access libraries
- Mixed approaches to error handling and response processing
- Duplication of common patterns (abort signal handling, response parsing)
- Lack of standardized model extension patterns
**Measurable goals:**
- Standardize API request patterns across all 4+ data-access libraries
- Reduce boilerplate code by 40% through shared utilities
- Improve type safety with comprehensive Zod schema coverage
### Scope
**In scope:**
- Schema validation patterns using Zod
- Model definition standards extending generated DTOs
- Service implementation patterns with generated Swagger clients
- Error handling and response processing standardization
- Integration with common utilities and logging
**Out of scope:**
- Modification of generated Swagger client code
- Changes to backend API contracts
- Authentication/authorization mechanisms
- Caching implementation details (handled by decorators)
## Decision
Implement a three-layer architecture pattern for data-access libraries:
1. **Schema Layer**: Use Zod schemas for input validation and type inference, following the naming convention `<Operation>Schema` with corresponding `<Operation>` and `<Operation>Input` types
2. **Model Layer**: Define domain-specific interfaces that extend generated DTOs, using `EntityContainer<T>` pattern for lazy-loaded relationships
3. **Service Layer**: Create injectable services that integrate generated Swagger clients, implement standardized error handling, and support request cancellation via AbortSignal
All data-access libraries will follow the standard export structure: `models`, `schemas`, `services`, and optionally `stores` and `helpers`.
## Rationale
**Alignment with strategic/technical direction:**
- Leverages existing Zod integration for consistent validation across the application
- Builds upon established generated Swagger client infrastructure
- Aligns with Angular dependency injection patterns and service architecture
- Supports the project's type-safety goals with TypeScript
**Trade-offs considered:**
- **Schema validation overhead**: Zod validation adds minimal runtime cost but provides significant development-time safety
- **Model extension complexity**: Interface extension pattern adds a layer but enables domain-specific enhancements
- **Service layer abstraction**: Additional abstraction over generated clients but enables consistent error handling and logging
**Evidence from current implementation:**
- Analysis of 4 data-access libraries shows successful patterns in `catalogue`, `remission`, `crm`, and `oms`
- `RemissionReturnReceiptService` demonstrates effective integration with logging and error handling
- `EntityContainer<T>` pattern proven effective for lazy-loaded relationships in remission domain
**Developer experience impact:**
- Consistent patterns reduce cognitive load when switching between domains
- Type inference from Zod schemas eliminates manual type definitions
- Standardized error handling reduces debugging time
- Auto-completion and type safety improve development velocity
**Long-term maintainability:**
- Clear separation of concerns between validation, models, and API integration
- Generated client changes don't break domain-specific model extensions
- Consistent logging and error handling simplifies troubleshooting
## Alternatives Considered
| Alternative | Summary | Pros | Cons | Reason Not Chosen |
|-------------|---------|------|------|-------------------|
| Option A | | | | |
| Option B | | | | |
| Option C | | | | |
Add deeper detail below if needed:
### Option A <name>
### Option B <name>
### Option C <name>
## Consequences
### Positive
-
### Negative / Risks / Debt Introduced
-
### Neutral / Open Questions
-
## Implementation Plan
### Phase 0 Analysis & Standards (Completed)
- ✅ Analyzed existing data-access libraries (`catalogue`, `remission`, `crm`, `oms`)
- ✅ Identified common patterns and best practices
- ✅ Documented standard library structure
### Phase 1 Common Utilities Enhancement
- Enhance `@isa/common/data-access` with additional utilities
- Add standardized error types and response handling
- Create reusable operators and decorators
- Add helper functions for common API patterns
### Phase 2 Template & Generator Creation
- Create Nx generator for new data-access libraries
- Develop template files for schemas, models, and services
- Add code snippets and documentation templates
- Create migration guide for existing libraries
### Phase 3 Existing Library Standardization
- Update `catalogue/data-access` to follow complete pattern
- Migrate `crm/data-access` to standard structure
- Ensure `remission/data-access` follows all conventions
- Standardize `oms/data-access` implementation
### Phase 4 New Library Implementation
- Apply patterns to new domain libraries as they're created
- Use Nx generator for consistent setup
- Enforce patterns through code review and linting
### Tasks / Workstreams
**Infrastructure:**
- Update `@isa/common/data-access` with enhanced utilities
- Add ESLint rules for data-access pattern enforcement
- Update `tsconfig.base.json` path mappings as needed
**Library Enhancements:**
- Create Nx generator: `nx g @isa/generators:data-access-lib <domain>`
- Add utility functions to `@isa/common/data-access`
- Enhanced error handling and logging patterns
**Migration Tasks:**
- Standardize schema validation across all libraries
- Ensure consistent model extension patterns
- Align service implementations with logging standards
- Update tests to match new patterns
**Documentation:**
- Create data-access implementation guide
- Update onboarding materials with patterns
- Add code examples to development wiki
- Document generator usage and options
### Acceptance Criteria
- [ ] All data-access libraries follow standardized structure
- [ ] All API requests use Zod schema validation
- [ ] All services implement consistent error handling
- [ ] All services support AbortSignal for cancellation
- [ ] All models extend generated DTOs appropriately
- [ ] Nx generator produces compliant library structure
- [ ] Code review checklist includes data-access patterns
- [ ] Performance benchmarks show no degradation
### Rollback Plan
- Individual library changes can be reverted via Git
- Generated libraries can be recreated with previous patterns
- No breaking changes to existing public APIs
- Gradual migration allows for partial rollback by domain
## Architectural Impact
### Nx / Monorepo Layout
- Data-access libraries follow domain-based organization: `libs/<domain>/data-access/`
- Each library exports standard modules: `models`, `schemas`, `services`
- Dependencies on `@isa/common/data-access` for shared utilities
- Integration with generated Swagger clients via `@generated/swagger/<api-name>`
### Module / Library Design
**Standard public API structure (`src/index.ts`):**
```typescript
export * from './lib/models';
export * from './lib/schemas';
export * from './lib/services';
// Optional: stores, helpers
```
**Path aliases in `tsconfig.base.json`:**
- `@isa/<domain>/data-access` for each domain library
- `@generated/swagger/<api-name>` for generated clients
- `@isa/common/data-access` for shared utilities
### State Management
- Services integrate with NgRx signal stores in feature libraries
- `EntityContainer<T>` pattern supports lazy loading in state management
- Resource factory pattern used for async state management (see remission examples)
- Caching implemented via decorators (`@Cache`, `@InFlight`)
### Runtime & Performance
- Zod schema validation adds minimal runtime overhead
- Generated clients are tree-shakeable
- AbortSignal support enables request cancellation
- Caching decorators reduce redundant API calls
- `firstValueFrom` pattern avoids memory leaks from subscriptions
### Security & Compliance
- All API calls go through generated clients with consistent auth handling
- Input validation via Zod schemas prevents injection attacks
- AbortSignal support enables proper request cleanup
- Logging excludes sensitive data through structured context
### Observability & Logging
- Consistent logging via `@isa/core/logging` with service-level context
- Structured logging with operation context and request metadata
- Error logging includes request details without sensitive data
- Debug logging for development troubleshooting
### DX / Tooling
- Consistent patterns reduce learning curve across domains
- Type inference from Zod schemas eliminates manual type definitions
- Auto-completion from TypeScript interfaces
- Standard error handling patterns
## Detailed Design Elements
### Schema Validation Pattern
**Structure:**
```typescript
// Input validation schema
export const SearchByTermSchema = z.object({
searchTerm: z.string().min(1, 'Search term must not be empty'),
skip: z.number().int().min(0).default(0),
take: z.number().int().min(1).max(100).default(20),
});
// Type inference
export type SearchByTerm = z.infer<typeof SearchByTermSchema>;
export type SearchByTermInput = z.input<typeof SearchByTermSchema>;
```
### Model Extension Pattern
**Generated DTO Extension:**
```typescript
import { ProductDTO } from '@generated/swagger/cat-search-api';
export interface Product extends ProductDTO {
name: string;
contributors: string;
catalogProductNumber: string;
// Domain-specific enhancements
}
```
**Entity Container Pattern:**
```typescript
export interface Return extends ReturnDTO {
id: number;
receipts: EntityContainer<Receipt>[]; // Lazy-loaded relationships
}
```
### Service Implementation Pattern
**Standard service structure:**
```typescript
@Injectable({ providedIn: 'root' })
export class DomainService {
#apiService = inject(GeneratedApiService);
#logger = logger(() => ({ service: 'DomainService' }));
async fetchData(params: InputType, abortSignal?: AbortSignal): Promise<ResultType> {
const validated = ValidationSchema.parse(params);
let req$ = this.#apiService.operation(validated);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
this.#logger.error('Operation failed', new Error(res.message));
throw new ResponseArgsError(res);
}
return res.result as ResultType;
}
}
```
### Error Handling Strategy
1. **Input Validation**: Zod schemas validate and transform inputs
2. **API Error Handling**: Check `res.error` from generated clients
3. **Structured Logging**: Log errors with context via `@isa/core/logging`
4. **Error Propagation**: Throw `ResponseArgsError` for consistent handling
### Concurrency & Cancellation
- **AbortSignal Support**: All async operations accept optional AbortSignal
- **RxJS Integration**: Use `takeUntilAborted` operator for cancellation
- **Promise Pattern**: `firstValueFrom` prevents subscription memory leaks
- **Caching**: `@InFlight` decorator prevents duplicate concurrent requests
### Extension Points
- **Custom Decorators**: `@Cache`, `@InFlight`, `@CacheTimeToLive`
- **Schema Transformations**: Zod `.transform()` for data normalization
- **Model Inheritance**: Interface extension for domain-specific properties
- **Service Composition**: Services can depend on other domain services
## Code Examples
### Complete Data-Access Library Structure
```typescript
// libs/domain/data-access/src/lib/schemas/fetch-items.schema.ts
import { z } from 'zod';
export const FetchItemsSchema = z.object({
categoryId: z.string().min(1),
skip: z.number().int().min(0).default(0),
take: z.number().int().min(1).max(100).default(20),
filters: z.record(z.any()).default({}),
});
export type FetchItems = z.infer<typeof FetchItemsSchema>;
export type FetchItemsInput = z.input<typeof FetchItemsSchema>;
// libs/domain/data-access/src/lib/models/item.ts
import { ItemDTO } from '@generated/swagger/domain-api';
import { EntityContainer } from '@isa/common/data-access';
import { Category } from './category';
export interface Item extends ItemDTO {
id: number;
displayName: string;
category: EntityContainer<Category>;
// Domain-specific enhancements
isAvailable: boolean;
formattedPrice: string;
}
// libs/domain/data-access/src/lib/services/item.service.ts
import { inject, Injectable } from '@angular/core';
import { ItemService as GeneratedItemService } from '@generated/swagger/domain-api';
import { firstValueFrom } from 'rxjs';
import { takeUntilAborted, ResponseArgsError } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { FetchItemsInput, FetchItemsSchema } from '../schemas';
import { Item } from '../models';
@Injectable({ providedIn: 'root' })
export class ItemService {
#itemService = inject(GeneratedItemService);
#logger = logger(() => ({ service: 'ItemService' }));
async fetchItems(
params: FetchItemsInput,
abortSignal?: AbortSignal
): Promise<Item[]> {
this.#logger.debug('Fetching items', () => ({ params }));
const { categoryId, skip, take, filters } = FetchItemsSchema.parse(params);
let req$ = this.#itemService.getItems({
categoryId,
queryToken: { skip, take, filter: filters }
});
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
this.#logger.error('Failed to fetch items', new Error(res.message));
throw new ResponseArgsError(res);
}
this.#logger.info('Successfully fetched items', () => ({
count: res.result?.length || 0
}));
return res.result as Item[];
}
}
// libs/domain/data-access/src/index.ts
export * from './lib/models';
export * from './lib/schemas';
export * from './lib/services';
```
### Usage in Feature Components
```typescript
// feature component using the data-access library
import { Component, inject, signal } from '@angular/core';
import { ItemService, Item, FetchItemsInput } from '@isa/domain/data-access';
@Component({
selector: 'app-item-list',
template: `
@if (loading()) {
<div>Loading...</div>
} @else {
@for (item of items(); track item.id) {
<div>{{ item.displayName }}</div>
}
}
`
})
export class ItemListComponent {
#itemService = inject(ItemService);
items = signal<Item[]>([]);
loading = signal(false);
async loadItems(categoryId: string) {
this.loading.set(true);
try {
const params: FetchItemsInput = {
categoryId,
take: 50,
filters: { active: true }
};
const items = await this.#itemService.fetchItems(params);
this.items.set(items);
} catch (error) {
console.error('Failed to load items', error);
} finally {
this.loading.set(false);
}
}
}
```
### Migration Pattern for Existing Services
```typescript
// Before: Direct HTTP client usage
@Injectable()
export class LegacyItemService {
constructor(private http: HttpClient) {}
getItems(categoryId: string): Observable<any> {
return this.http.get(`/api/items?category=${categoryId}`);
}
}
// After: Standardized data-access pattern
@Injectable({ providedIn: 'root' })
export class ItemService {
#itemService = inject(GeneratedItemService);
#logger = logger(() => ({ service: 'ItemService' }));
async fetchItems(params: FetchItemsInput, abortSignal?: AbortSignal): Promise<Item[]> {
const validated = FetchItemsSchema.parse(params);
// ... standard implementation pattern
}
}
```
## Open Questions / Follow-Ups
- Unresolved design clarifications
- Dependent ADRs required
- External approvals needed
## Decision Review & Revalidation
When and how this ADR will be re-evaluated (date, trigger conditions, metrics thresholds).
## Status Log
| Date | Change | Author |
|------|--------|--------|
| 2025-09-29 | Created (Draft) | Lorenz, Nino |
| 2025-09-25 | Analysis completed, comprehensive patterns documented | AI Assistant |
## References
**Existing Implementation Examples:**
- `libs/catalogue/data-access` - Basic schema and service patterns
- `libs/remission/data-access` - Advanced patterns with EntityContainer and stores
- `libs/common/data-access` - Shared utilities and response types
- `generated/swagger/` - Generated API clients integration
**Key Dependencies:**
- [Zod](https://github.com/colinhacks/zod) - Schema validation library
- [ng-swagger-gen](https://github.com/cyclosproject/ng-swagger-gen) - OpenAPI client generation
- `@isa/core/logging` - Structured logging infrastructure
- `@isa/common/data-access` - Shared utilities and types
**Related Documentation:**
- ISA Frontend Copilot Instructions - Data-access patterns
- Tech Stack Documentation - Architecture overview
- Code Style Guidelines - TypeScript and Angular patterns
---
> Document updates MUST reference this ADR number in commit messages: `ADR-NNNN:` prefix.
> Keep this document updated through all lifecycle stages.

View File

@@ -0,0 +1,2 @@
export * from './models';
export * from './services';

View File

@@ -1 +1,2 @@
export * from './create-esc-abort-controller.helper';
export * from './zod-error.helper';

View File

@@ -0,0 +1,128 @@
import { ZodError, z } from 'zod';
import { extractZodErrorMessage } from './zod-error.helper';
describe('ZodErrorHelper', () => {
describe('extractZodErrorMessage', () => {
it('should return default message for empty issues', () => {
const error = new ZodError([]);
const result = extractZodErrorMessage(error);
expect(result).toBe('Unbekannter Validierungsfehler aufgetreten.');
});
it('should format single invalid_type error', () => {
const schema = z.string();
try {
schema.parse(123);
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('Erwartet: Text, erhalten: Zahl');
}
}
});
it('should format invalid_string error for email validation', () => {
const schema = z.object({
email: z.string().email(),
});
try {
schema.parse({ email: 'invalid-email' });
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('email: Ungültige E-Mail-Adresse');
}
}
});
it('should format too_small error for strings', () => {
const schema = z.object({
name: z.string().min(5),
});
try {
schema.parse({ name: 'ab' });
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('name: Text muss mindestens 5 Zeichen lang sein');
}
}
});
it('should format multiple errors with bullet points', () => {
const schema = z.object({
name: z.string(),
age: z.number(),
});
try {
schema.parse({ name: 123, age: 'not-a-number' });
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toContain('Es sind 2 Validierungsfehler aufgetreten:');
expect(result).toContain('• name: Erwartet: Text, erhalten: Zahl');
expect(result).toContain('• age: Erwartet: Zahl, erhalten: Text');
}
}
});
it('should format nested path correctly', () => {
const schema = z.object({
user: z.object({
profile: z.object({
email: z.string().email(),
}),
}),
});
try {
schema.parse({
user: {
profile: {
email: 'invalid-email',
},
},
});
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('user → profile → email: Ungültige E-Mail-Adresse');
}
}
});
it('should handle array indices in paths', () => {
const schema = z.array(z.string());
try {
schema.parse(['valid', 123, 'also-valid']);
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('[1]: Erwartet: Text, erhalten: Zahl');
}
}
});
it('should handle custom error messages', () => {
const schema = z.string().refine((val) => val === 'specific', {
message: 'Must be exactly "specific"',
});
try {
schema.parse('wrong');
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('Must be exactly "specific"');
}
}
});
});
});

View File

@@ -0,0 +1,226 @@
import { ZodError, ZodIssue } from 'zod';
/**
* Extracts and formats human-readable error messages from ZodError instances.
*
* This function processes Zod validation errors and transforms them into
* user-friendly messages that can be displayed in UI components.
*
* @param error - The ZodError instance to extract messages from
* @returns A formatted string containing all validation error messages
*
* @example
* ```typescript
* try {
* schema.parse(data);
* } catch (error) {
* if (error instanceof ZodError) {
* const message = extractZodErrorMessage(error);
* // Display message to user
* }
* }
* ```
*/
export function extractZodErrorMessage(error: ZodError): string {
if (!error.issues || error.issues.length === 0) {
return 'Unbekannter Validierungsfehler aufgetreten.';
}
const messages = error.issues.map((issue) => formatZodIssue(issue));
// Remove duplicates and join with line breaks
const uniqueMessages = Array.from(new Set(messages));
if (uniqueMessages.length === 1) {
return uniqueMessages[0];
}
return `Es sind ${uniqueMessages.length} Validierungsfehler aufgetreten:\n\n${uniqueMessages.map(msg => `${msg}`).join('\n')}`;
}
/**
* Formats a single ZodIssue into a human-readable message.
*
* @param issue - The ZodIssue to format
* @returns A formatted error message string
*/
function formatZodIssue(issue: ZodIssue): string {
const fieldPath = formatFieldPath(issue.path);
const fieldPrefix = fieldPath ? `${fieldPath}: ` : '';
switch (issue.code) {
case 'invalid_type':
return `${fieldPrefix}${formatInvalidTypeMessage(issue)}`;
case 'too_small':
return `${fieldPrefix}${formatTooSmallMessage(issue)}`;
case 'too_big':
return `${fieldPrefix}${formatTooBigMessage(issue)}`;
case 'invalid_string':
return `${fieldPrefix}${formatInvalidStringMessage(issue)}`;
case 'unrecognized_keys':
return `${fieldPrefix}Unbekannte Felder: ${issue.keys?.join(', ')}`;
case 'invalid_union':
return `${fieldPrefix}Wert entspricht nicht den erwarteten Optionen`;
case 'invalid_enum_value':
return `${fieldPrefix}Ungültiger Wert. Erlaubt sind: ${issue.options?.join(', ')}`;
case 'invalid_arguments':
return `${fieldPrefix}Ungültige Parameter`;
case 'invalid_return_type':
return `${fieldPrefix}Ungültiger Rückgabetyp`;
case 'invalid_date':
return `${fieldPrefix}Ungültiges Datum`;
case 'invalid_literal':
return `${fieldPrefix}Wert muss exakt '${issue.expected}' sein`;
case 'custom':
return `${fieldPrefix}${issue.message || 'Benutzerdefinierte Validierung fehlgeschlagen'}`;
default:
return `${fieldPrefix}${issue.message || 'Validierungsfehler'}`;
}
}
/**
* Formats a field path array into a human-readable string.
*
* @param path - Array of field path segments
* @returns Formatted path string or empty string if no path
*/
function formatFieldPath(path: (string | number)[]): string {
if (!path || path.length === 0) {
return '';
}
return path
.map((segment) => {
if (typeof segment === 'number') {
return `[${segment}]`;
}
return segment;
})
.join(' → ');
}
/**
* Formats invalid type error messages with German translations.
*/
function formatInvalidTypeMessage(issue: ZodIssue & { expected: string; received: string }): string {
const typeTranslations: Record<string, string> = {
string: 'Text',
number: 'Zahl',
boolean: 'Ja/Nein-Wert',
object: 'Objekt',
array: 'Liste',
date: 'Datum',
undefined: 'undefiniert',
null: 'null',
bigint: 'große Zahl',
function: 'Funktion',
symbol: 'Symbol',
};
const expected = typeTranslations[issue.expected] || issue.expected;
const received = typeTranslations[issue.received] || issue.received;
return `Erwartet: ${expected}, erhalten: ${received}`;
}
/**
* Formats "too small" error messages based on the type.
*/
function formatTooSmallMessage(issue: any): string {
const { type, minimum, inclusive } = issue;
const operator = inclusive ? 'mindestens' : 'mehr als';
switch (type) {
case 'string':
return `Text muss ${operator} ${minimum} Zeichen lang sein`;
case 'number':
case 'bigint':
return `Wert muss ${operator} ${minimum} sein`;
case 'array':
return `Liste muss ${operator} ${minimum} Elemente enthalten`;
case 'set':
return `Set muss ${operator} ${minimum} Elemente enthalten`;
case 'date':
const minDate = typeof minimum === 'bigint' ? new Date(Number(minimum)) : new Date(minimum);
return `Datum muss ${operator} ${minDate.toLocaleDateString('de-DE')} sein`;
default:
return `Wert ist zu klein (min: ${minimum})`;
}
}
/**
* Formats "too big" error messages based on the type.
*/
function formatTooBigMessage(issue: any): string {
const { type, maximum, inclusive } = issue;
const operator = inclusive ? 'höchstens' : 'weniger als';
switch (type) {
case 'string':
return `Text darf ${operator} ${maximum} Zeichen lang sein`;
case 'number':
case 'bigint':
return `Wert darf ${operator} ${maximum} sein`;
case 'array':
return `Liste darf ${operator} ${maximum} Elemente enthalten`;
case 'set':
return `Set darf ${operator} ${maximum} Elemente enthalten`;
case 'date':
const maxDate = typeof maximum === 'bigint' ? new Date(Number(maximum)) : new Date(maximum);
return `Datum darf ${operator} ${maxDate.toLocaleDateString('de-DE')} sein`;
default:
return `Wert ist zu groß (max: ${maximum})`;
}
}
/**
* Formats invalid string error messages based on validation type.
*/
function formatInvalidStringMessage(issue: any): string {
let validation = 'unknown';
if (typeof issue.validation === 'string') {
validation = issue.validation;
} else if (typeof issue.validation === 'object' && issue.validation) {
if ('includes' in issue.validation) {
validation = 'includes';
} else if ('startsWith' in issue.validation) {
validation = 'startsWith';
} else if ('endsWith' in issue.validation) {
validation = 'endsWith';
} else {
validation = Object.keys(issue.validation)[0] || 'unknown';
}
}
const validationMessages: Record<string, string> = {
email: 'Ungültige E-Mail-Adresse',
url: 'Ungültige URL',
uuid: 'Ungültige UUID',
cuid: 'Ungültige CUID',
cuid2: 'Ungültige CUID2',
ulid: 'Ungültige ULID',
regex: 'Format entspricht nicht dem erwarteten Muster',
datetime: 'Ungültiges Datum/Zeit-Format',
ip: 'Ungültige IP-Adresse',
emoji: 'Muss ein Emoji sein',
includes: 'Text muss bestimmte Zeichen enthalten',
startsWith: 'Text muss mit bestimmten Zeichen beginnen',
endsWith: 'Text muss mit bestimmten Zeichen enden',
length: 'Text hat eine ungültige Länge',
};
return validationMessages[validation] || `Ungültiges Format (${validation})`;
}

View File

@@ -1,17 +1,194 @@
# Common Decorators Library
# @isa/common/decorators
A collection of TypeScript decorators for common cross-cutting concerns in Angular applications.
A comprehensive collection of TypeScript decorators for enhancing method behavior in Angular applications. This library provides decorators for validation, caching, debouncing, rate limiting, and more.
## Installation
This library is already configured in the project's `tsconfig.base.json`. Import decorators using:
```typescript
import { InFlight, InFlightWithKey, InFlightWithCache } from '@isa/common/decorators';
import {
// Validation decorators
ValidateParams, ValidateParam, ZodValidationError,
// Caching decorators
Cache, CacheTimeToLive,
// Rate limiting decorators
Debounce, InFlight, InFlightWithKey, InFlightWithCache
} from '@isa/common/decorators';
```
## Available Decorators
### 🛡️ Validation Decorators
#### `ValidateParams`
Method decorator that validates method parameters using Zod schemas at runtime.
**Features:**
- Runtime type validation with Zod
- Custom parameter names for better error messages
- Schema transformations and default values
- Async method support
- Selective parameter validation
```typescript
import { ValidateParams, ZodValidationError } from '@isa/common/decorators';
import { z } from 'zod';
@Injectable()
class UserService {
@ValidateParams([
z.string().email(),
z.object({
name: z.string().min(1),
age: z.number().int().min(0).max(120)
})
], ['email', 'userData'])
async createUser(email: string, userData: UserData): Promise<User> {
// Method implementation - parameters are validated before execution
return await this.apiClient.createUser(email, userData);
}
}
// Usage with error handling
try {
await userService.createUser('invalid-email', userData);
} catch (error) {
if (error instanceof ZodValidationError) {
console.log(`Validation failed: ${error.message}`);
console.log('Parameter:', error.parameterName);
console.log('Details:', error.zodError.issues);
}
}
```
#### `ValidateParam`
Method decorator for validating a single parameter at a specific index.
```typescript
@Injectable()
class EmailService {
@ValidateParam(0, z.string().email(), 'emailAddress')
async sendEmail(email: string, subject: string): Promise<void> {
// Only the first parameter (email) is validated
return await this.emailClient.send(email, subject);
}
}
```
**Advanced Validation Examples:**
```typescript
class AdvancedValidationService {
// Complex object validation with transformations
@ValidateParams([
z.string().transform(s => s.trim().toLowerCase()),
z.array(z.string().uuid()).min(1),
z.object({
priority: z.enum(['low', 'medium', 'high']).default('medium'),
retries: z.number().int().min(0).max(5).default(3)
}).optional()
])
processRequest(name: string, ids: string[], options?: RequestOptions): void {
// name is trimmed and lowercase, options have defaults applied
}
// Skip validation for specific parameters
@ValidateParams([
z.string().email(), // Validate first parameter
undefined, // Skip second parameter
z.number().positive() // Validate third parameter
])
mixedValidation(email: string, metadata: any, count: number): void {
// email and count validated, metadata can be anything
}
}
```
### 📦 Caching Decorators
#### `Cache`
Method decorator that caches the results of both synchronous and asynchronous method calls.
**Features:**
- Automatic caching based on method arguments
- TTL (Time To Live) support with predefined constants
- Custom cache key generation
- Separate cache per instance
- Error handling (errors are not cached)
```typescript
import { Cache, CacheTimeToLive } from '@isa/common/decorators';
@Injectable()
class DataService {
@Cache({
ttl: CacheTimeToLive.fiveMinutes,
keyGenerator: (query: string) => `search-${query}`
})
async searchData(query: string): Promise<SearchResult[]> {
// Expensive API call - results cached for 5 minutes
return await this.apiClient.search(query);
}
@Cache({ ttl: CacheTimeToLive.oneHour })
calculateComplexResult(input: number): number {
// Expensive calculation - cached for 1 hour
return this.performHeavyCalculation(input);
}
}
```
**Cache TTL Constants:**
- `CacheTimeToLive.oneMinute` (60,000ms)
- `CacheTimeToLive.fiveMinutes` (300,000ms)
- `CacheTimeToLive.tenMinutes` (600,000ms)
- `CacheTimeToLive.thirtyMinutes` (1,800,000ms)
- `CacheTimeToLive.oneHour` (3,600,000ms)
### ⏱️ Rate Limiting Decorators
#### `Debounce`
Method decorator that debounces method calls using lodash's debounce function.
**Features:**
- Configurable wait time
- Leading and trailing edge execution
- Maximum wait time limits
- Separate debounced functions per instance
```typescript
import { Debounce } from '@isa/common/decorators';
@Injectable()
class SearchService {
@Debounce({ wait: 300 })
performSearch(query: string): void {
// Called only after 300ms of no additional calls
console.log('Searching for:', query);
}
@Debounce({
wait: 500,
leading: true,
trailing: false
})
saveData(data: any): void {
// Executes immediately on first call, then debounces
console.log('Saving:', data);
}
@Debounce({
wait: 1000,
maxWait: 5000
})
autoSave(): void {
// Forces execution after 5 seconds even with continuous calls
console.log('Auto-saving...');
}
}
```
### 🚀 InFlight Decorators
Prevent multiple simultaneous calls to the same async method. All concurrent calls receive the same Promise result.
@@ -167,8 +344,248 @@ class OrderService {
}
```
## Advanced Usage Examples
### Combining Decorators
Decorators can be combined for powerful behavior:
```typescript
@Injectable()
class AdvancedService {
@Cache({ ttl: CacheTimeToLive.tenMinutes })
@Debounce({ wait: 500 })
@ValidateParams([z.string().min(1)], ['query'])
async searchWithCacheAndDebounce(query: string): Promise<SearchResult[]> {
// 1. Parameter is validated
// 2. Call is debounced by 500ms
// 3. Result is cached for 10 minutes
return await this.performSearch(query);
}
@InFlight()
@ValidateParams([
z.string().uuid(),
z.object({
retries: z.number().int().min(0).max(5).default(3),
timeout: z.number().positive().default(5000)
}).optional()
], ['operationId', 'options'])
async executeOperation(
operationId: string,
options?: { retries?: number; timeout?: number }
): Promise<void> {
// 1. Parameters are validated with defaults applied
// 2. Only one execution per instance allowed
await this.performOperation(operationId, options);
}
}
```
### Complex Real-World Examples
```typescript
@Injectable()
class ProductService {
// Comprehensive product search with validation, debouncing, and caching
@Cache({ ttl: CacheTimeToLive.fiveMinutes })
@Debounce({ wait: 300 })
@ValidateParams([
z.object({
query: z.string().min(1).transform(s => s.trim()),
category: z.string().optional(),
priceRange: z.object({
min: z.number().min(0),
max: z.number().positive()
}).optional(),
sortBy: z.enum(['price', 'name', 'rating']).default('name'),
page: z.number().int().min(1).default(1)
})
], ['searchParams'])
async searchProducts(params: ProductSearchParams): Promise<ProductResults> {
return await this.apiClient.searchProducts(params);
}
// Bulk operations with validation and in-flight protection
@InFlightWithKey({
keyGenerator: (operation: string) => `bulk-${operation}`
})
@ValidateParams([
z.enum(['update', 'delete', 'activate', 'deactivate']),
z.array(z.string().uuid()).min(1).max(100),
z.object({
batchSize: z.number().int().positive().max(50).default(10),
delayMs: z.number().min(0).default(100)
}).optional()
], ['operation', 'productIds', 'options'])
async bulkOperation(
operation: BulkOperationType,
productIds: string[],
options?: BulkOptions
): Promise<BulkOperationResult> {
return await this.performBulkOperation(operation, productIds, options);
}
}
@Injectable()
class UserManagementService {
// User creation with comprehensive validation
@ValidateParams([
z.object({
email: z.string().email().transform(e => e.toLowerCase()),
name: z.string().min(1).max(100).transform(n => n.trim()),
role: z.enum(['admin', 'user', 'manager']).default('user'),
permissions: z.array(z.string()).default([]),
metadata: z.record(z.string()).optional()
}),
z.object({
sendWelcomeEmail: z.boolean().default(true),
skipValidation: z.boolean().default(false),
auditLog: z.boolean().default(true)
}).optional()
], ['userData', 'options'])
async createUser(
userData: CreateUserData,
options?: CreateUserOptions
): Promise<User> {
// All parameters are validated and transformed
return await this.userRepository.create(userData, options);
}
// Password reset with rate limiting and caching
@Cache({ ttl: CacheTimeToLive.oneMinute }) // Prevent spam
@Debounce({ wait: 2000, leading: true, trailing: false })
@ValidateParams([z.string().email()], ['email'])
async initiatePasswordReset(email: string): Promise<void> {
await this.authService.sendPasswordResetEmail(email);
}
}
```
### Error Handling Patterns
```typescript
@Injectable()
class RobustService {
@Cache({ ttl: CacheTimeToLive.fiveMinutes })
@InFlight()
@ValidateParams([z.string().url()], ['endpoint'])
async fetchWithRetry(endpoint: string): Promise<ApiResponse> {
try {
const response = await this.httpClient.get(endpoint);
return response.data;
} catch (error) {
// Errors are not cached, in-flight tracking is cleaned up
console.error('API call failed:', error);
throw new Error(`Failed to fetch data from ${endpoint}: ${error.message}`);
}
}
// Graceful error handling in validation
async processUserInput(input: unknown): Promise<ProcessResult> {
try {
// Manual validation for dynamic scenarios
const validatedInput = z.object({
action: z.enum(['create', 'update', 'delete']),
data: z.record(z.any())
}).parse(input);
return await this.processValidatedInput(validatedInput);
} catch (error) {
if (error instanceof ZodValidationError) {
return {
success: false,
error: 'Invalid input format',
details: error.zodError.issues
};
}
throw error;
}
}
@ValidateParams([
z.object({
action: z.enum(['create', 'update', 'delete']),
data: z.record(z.any())
})
])
async processValidatedInput(input: ProcessInput): Promise<ProcessResult> {
// Input is guaranteed to be valid
return { success: true, result: await this.execute(input) };
}
}
```
## Best Practices
### ✅ Validation Best Practices
- **Use descriptive parameter names** for better error messages
- **Define schemas separately** for complex validations and reuse them
- **Use transformations** to normalize input data (trim, lowercase, etc.)
- **Provide defaults** for optional parameters using Zod's `.default()`
- **Validate at service boundaries** (API endpoints, user input handlers)
```typescript
// ✅ Good: Reusable schema with descriptive names
const UserSchema = z.object({
email: z.string().email().transform(e => e.toLowerCase()),
name: z.string().min(1).transform(n => n.trim())
});
@ValidateParams([UserSchema], ['userData'])
createUser(userData: UserData): Promise<User> { }
// ❌ Avoid: Inline complex schemas without parameter names
@ValidateParams([z.object({ /* complex schema */ })])
createUser(userData: any): Promise<User> { }
```
### ✅ Caching Best Practices
- **Use appropriate TTL** based on data volatility
- **Custom key generators** for complex parameters
- **Cache expensive operations** only
- **Don't cache operations with side effects**
```typescript
// ✅ Good: Appropriate TTL and custom key generation
@Cache({
ttl: CacheTimeToLive.fiveMinutes,
keyGenerator: (filter: SearchFilter) =>
`search-${filter.category}-${filter.sortBy}-${filter.page}`
})
searchProducts(filter: SearchFilter): Promise<Product[]> { }
// ❌ Avoid: Caching operations with side effects
@Cache()
async sendEmail(to: string): Promise<void> { } // Don't cache this!
```
### ✅ Debouncing Best Practices
- **Debounce user input handlers** (search, form validation)
- **Use leading edge** for immediate feedback actions
- **Set maxWait** for critical operations that must eventually execute
- **Consider user experience** - don't make interactions feel sluggish
```typescript
// ✅ Good: User input debouncing
@Debounce({ wait: 300 })
onSearchInputChange(query: string): void { }
// ✅ Good: Button click with immediate feedback
@Debounce({ wait: 1000, leading: true, trailing: false })
onSaveButtonClick(): void { }
```
### ✅ InFlight Best Practices
- **Use for expensive async operations** to prevent duplicates
- **Use InFlightWithKey** when parameters affect the result
- **Use InFlightWithCache** for data that doesn't change frequently
- **Don't use on methods with side effects** unless intentional
### ✅ Do
- Use `@InFlight()` for simple methods without parameters
@@ -268,10 +685,321 @@ class MyService {
}
```
## Testing Decorators
When testing methods with decorators, consider the decorator behavior:
```typescript
import { ZodValidationError } from '@isa/common/decorators';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
service = new UserService();
jest.clearAllMocks();
});
describe('validation', () => {
it('should validate email parameter', () => {
// Test valid input
expect(() => service.createUser('valid@email.com', userData))
.not.toThrow();
// Test invalid input
expect(() => service.createUser('invalid-email', userData))
.toThrow(ZodValidationError);
});
it('should provide detailed error information', () => {
try {
service.createUser('invalid-email', userData);
} catch (error) {
expect(error).toBeInstanceOf(ZodValidationError);
expect(error.parameterName).toBe('email');
expect(error.parameterIndex).toBe(0);
}
});
});
describe('caching', () => {
it('should cache expensive operations', async () => {
const expensiveOperationSpy = jest.spyOn(service, 'expensiveOperation');
// First call
await service.cachedMethod('param');
expect(expensiveOperationSpy).toHaveBeenCalledTimes(1);
// Second call with same parameters (should use cache)
await service.cachedMethod('param');
expect(expensiveOperationSpy).toHaveBeenCalledTimes(1);
// Third call with different parameters (should not use cache)
await service.cachedMethod('different');
expect(expensiveOperationSpy).toHaveBeenCalledTimes(2);
});
});
describe('debouncing', () => {
it('should debounce method calls', (done) => {
const debouncedSpy = jest.spyOn(service, 'actualMethod');
// Call multiple times rapidly
service.debouncedMethod('param1');
service.debouncedMethod('param2');
service.debouncedMethod('param3');
// Should not have been called yet
expect(debouncedSpy).not.toHaveBeenCalled();
// Wait for debounce period
setTimeout(() => {
expect(debouncedSpy).toHaveBeenCalledTimes(1);
expect(debouncedSpy).toHaveBeenCalledWith('param3');
done();
}, 350); // Assuming 300ms debounce
});
});
describe('in-flight protection', () => {
it('should prevent duplicate async calls', async () => {
const apiCallSpy = jest.spyOn(service, 'apiCall');
// Start multiple calls simultaneously
const promises = [
service.inFlightMethod(),
service.inFlightMethod(),
service.inFlightMethod()
];
await Promise.all(promises);
// Should only have made one actual API call
expect(apiCallSpy).toHaveBeenCalledTimes(1);
});
});
});
```
### Testing with Vitest
For projects using Vitest (new standard):
```typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ZodValidationError } from '@isa/common/decorators';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
service = new UserService();
vi.clearAllMocks();
});
it('should validate parameters', () => {
expect(() => service.validateMethod('valid-input')).not.toThrow();
expect(() => service.validateMethod('')).toThrow(ZodValidationError);
});
});
```
## TypeScript Configuration
Ensure your `tsconfig.json` has experimental decorators enabled:
```json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"lib": ["ES2022", "DOM"]
}
}
```
## Dependencies
This library depends on:
- **Zod** (v3.24.2) - Schema validation library for the validation decorators
- **Lodash** - Specifically the `debounce` function for the Debounce decorator
- **TypeScript** - Experimental decorators support required
All dependencies are already configured in the project's package.json.
## Performance Considerations
### Memory Usage
- **Validation**: Adds runtime overhead for parameter validation
- **Cache**: Memory usage scales with cached results - set appropriate TTLs
- **Debounce**: Minimal memory overhead, one timer per method instance
- **InFlight**: Memory usage scales with concurrent operations
### Execution Overhead
- **Validation**: Schema validation adds ~0.1-1ms per call depending on complexity
- **Cache**: Cache lookups are generally < 0.1ms
- **Debounce**: Timer management adds minimal overhead
- **InFlight**: Promise management adds ~0.1ms per call
### Optimization Tips
1. **Use specific Zod schemas** - avoid overly complex validations in hot paths
2. **Set reasonable cache TTLs** - balance memory usage vs. performance gains
3. **Profile critical paths** - measure the impact of decorators in performance-critical code
4. **Consider decorator order** - validation → debouncing → caching → in-flight typically works best
## Quick Reference
### Decorator Comparison Table
| Decorator | Purpose | Use Case | Key Features | Example |
|-----------|---------|----------|--------------|---------|
| `ValidateParams` | Parameter validation | API inputs, user data | Zod schemas, transformations, custom errors | `@ValidateParams([z.string().email()])` |
| `ValidateParam` | Single parameter validation | Simple validation | Index-based, custom names | `@ValidateParam(0, z.string())` |
| `Cache` | Result caching | Expensive operations | TTL, custom keys, per-instance | `@Cache({ ttl: CacheTimeToLive.fiveMinutes })` |
| `Debounce` | Rate limiting | User input, auto-save | Wait time, leading/trailing edge | `@Debounce({ wait: 300 })` |
| `InFlight` | Duplicate prevention | API calls | Promise sharing, per-instance | `@InFlight()` |
| `InFlightWithKey` | Keyed duplicate prevention | Parameterized API calls | Argument-based keys | `@InFlightWithKey()` |
| `InFlightWithCache` | Cache + duplicate prevention | Expensive + stable data | Combined caching and deduplication | `@InFlightWithCache({ cacheTime: 60000 })` |
### Common Patterns
```typescript
// Form validation with debouncing
@Debounce({ wait: 300 })
@ValidateParams([z.string().min(1)], ['query'])
onSearchInput(query: string) { }
// API call with caching and duplicate prevention
@Cache({ ttl: CacheTimeToLive.fiveMinutes })
@InFlight()
@ValidateParams([z.string().uuid()], ['id'])
async fetchUser(id: string): Promise<User> { }
// Complex validation with transformation
@ValidateParams([
z.object({
email: z.string().email().transform(e => e.toLowerCase()),
data: z.record(z.any()).transform(d => sanitizeData(d))
})
])
processUserData(input: UserInput) { }
```
## Migration Guide
### Migrating from Manual Validation
```typescript
// Before: Manual validation
class OldService {
async createUser(email: string, userData: any): Promise<User> {
if (!email || !isValidEmail(email)) {
throw new Error('Invalid email');
}
if (!userData || !userData.name) {
throw new Error('Name is required');
}
// ... rest of method
}
}
// After: Decorator validation
class NewService {
@ValidateParams([
z.string().email(),
z.object({ name: z.string().min(1) })
], ['email', 'userData'])
async createUser(email: string, userData: UserData): Promise<User> {
// Parameters are guaranteed to be valid
// ... rest of method
}
}
```
### Migrating from RxJS Patterns
```typescript
// Before: RxJS shareReplay pattern
class OldService {
private userCache$ = new BehaviorSubject<User[]>([]);
getUsers(): Observable<User[]> {
return this.http.get<User[]>('/users').pipe(
tap(users => this.userCache$.next(users)),
shareReplay(1)
);
}
}
// After: Decorator pattern
class NewService {
@Cache({ ttl: CacheTimeToLive.fiveMinutes })
@InFlight()
async getUsers(): Promise<User[]> {
const response = await this.http.get<User[]>('/users').toPromise();
return response;
}
}
```
## Troubleshooting
### Common Issues
**Decorators not working:**
- Ensure `experimentalDecorators: true` in tsconfig.json
- Check decorator import statements
- Verify TypeScript version supports decorators
**Validation errors:**
- Check Zod schema definitions
- Verify parameter names match expectations
- Use `ZodValidationError` for proper error handling
**Caching not working:**
- Verify TTL settings are appropriate
- Check if custom key generators are working correctly
- Ensure methods are not throwing errors (errors are not cached)
**Performance issues:**
- Profile decorator overhead in critical paths
- Consider simpler validation schemas for hot code
- Review cache TTL settings for memory usage
### Debug Mode
Enable debug logging for decorators:
```typescript
// In development environment
if (environment.development) {
// Enable Zod error details
z.setErrorMap((issue, ctx) => ({
message: `${ctx.defaultError} (Path: ${issue.path.join('.')})`
}));
}
```
## License
This library is part of the ISA Frontend project and follows the project's licensing terms.
---
## Contributing
When adding new decorators:
1. Add implementation in `src/lib/`
2. Include comprehensive unit tests
3. Update this documentation
4. Export from `src/index.ts`
1. **Implementation**: Add in `src/lib/[decorator-name].decorator.ts`
2. **Tests**: Include comprehensive unit tests with edge cases
3. **Documentation**: Update this README with examples and usage
4. **Exports**: Export from `src/index.ts`
5. **Examples**: Add real-world usage examples
6. **Performance**: Consider memory and execution overhead
For more detailed examples and usage patterns, see [USAGE.md](./USAGE.md).
---
*This comprehensive decorator library enhances Angular applications with powerful cross-cutting concerns while maintaining clean, readable code. Each decorator is designed to be composable, performant, and easy to test.*

View File

@@ -1,2 +1,4 @@
export * from './lib/in-flight.decorator';
export * from './lib/cache.decorator';
export * from './lib/cache.decorator';
export * from './lib/debounce.decorator';
export * from './lib/zod-validate.decorator';

View File

@@ -0,0 +1,272 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Debounce } from './debounce.decorator';
describe('Debounce Decorator', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
});
describe('Basic debouncing', () => {
class TestService {
callCount = 0;
lastArgs: any[] = [];
@Debounce({ wait: 300 })
debouncedMethod(value: string): void {
this.callCount++;
this.lastArgs = [value];
}
@Debounce({ wait: 100 })
debouncedWithMultipleArgs(a: number, b: string): void {
this.callCount++;
this.lastArgs = [a, b];
}
}
it('should debounce method calls', () => {
const service = new TestService();
// Call method multiple times rapidly
service.debouncedMethod('first');
service.debouncedMethod('second');
service.debouncedMethod('third');
// Should not have been called yet
expect(service.callCount).toBe(0);
// Fast forward past debounce delay
vi.advanceTimersByTime(300);
// Should have been called once with the last arguments
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual(['third']);
});
it('should handle multiple arguments correctly', () => {
const service = new TestService();
service.debouncedWithMultipleArgs(1, 'a');
service.debouncedWithMultipleArgs(2, 'b');
service.debouncedWithMultipleArgs(3, 'c');
expect(service.callCount).toBe(0);
vi.advanceTimersByTime(100);
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual([3, 'c']);
});
it('should reset timer on subsequent calls', () => {
const service = new TestService();
service.debouncedMethod('first');
// Advance time but not enough to trigger
vi.advanceTimersByTime(200);
expect(service.callCount).toBe(0);
// Call again, should reset the timer
service.debouncedMethod('second');
// Advance by full delay from second call
vi.advanceTimersByTime(300);
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual(['second']);
});
});
describe('Leading edge execution', () => {
class TestService {
callCount = 0;
lastArgs: any[] = [];
@Debounce({ wait: 300, leading: true, trailing: false })
leadingMethod(value: string): void {
this.callCount++;
this.lastArgs = [value];
}
@Debounce({ wait: 300, leading: true, trailing: true })
bothEdgesMethod(value: string): void {
this.callCount++;
this.lastArgs = [value];
}
}
it('should execute on leading edge when configured', () => {
const service = new TestService();
service.leadingMethod('first');
// Should execute immediately on leading edge
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual(['first']);
// Additional calls within debounce period should not execute
service.leadingMethod('second');
service.leadingMethod('third');
vi.advanceTimersByTime(300);
// Still should only have been called once (trailing: false)
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual(['first']);
});
it('should execute on both edges when configured', () => {
const service = new TestService();
service.bothEdgesMethod('first');
// Should execute immediately on leading edge
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual(['first']);
// Additional calls within debounce period
service.bothEdgesMethod('second');
service.bothEdgesMethod('third');
vi.advanceTimersByTime(300);
// Should have been called twice (leading + trailing)
expect(service.callCount).toBe(2);
expect(service.lastArgs).toEqual(['third']);
});
});
describe('MaxWait option', () => {
class TestService {
callCount = 0;
lastArgs: any[] = [];
@Debounce({ wait: 1000, maxWait: 2000 })
maxWaitMethod(value: string): void {
this.callCount++;
this.lastArgs = [value];
}
}
it('should respect maxWait limit', () => {
const service = new TestService();
// Start calling repeatedly
service.maxWaitMethod('first');
expect(service.callCount).toBe(0);
// Keep calling every 900ms (less than wait time)
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(900);
service.maxWaitMethod(`call-${i}`);
if (i < 2) {
expect(service.callCount).toBe(0); // Should not execute yet
}
}
// After maxWait time (2000ms), should execute despite continuous calls
vi.advanceTimersByTime(200); // Total: ~2000ms
expect(service.callCount).toBeGreaterThan(0);
});
});
describe('Multiple instances', () => {
class TestService {
callCount = 0;
@Debounce({ wait: 300 })
debouncedMethod(): void {
this.callCount++;
}
}
it('should maintain separate debounced functions per instance', () => {
const service1 = new TestService();
const service2 = new TestService();
// Call methods on both instances
service1.debouncedMethod();
service2.debouncedMethod();
// Both should be independent
expect(service1.callCount).toBe(0);
expect(service2.callCount).toBe(0);
vi.advanceTimersByTime(300);
// Both should have been called once
expect(service1.callCount).toBe(1);
expect(service2.callCount).toBe(1);
});
it('should not interfere between instances', () => {
const service1 = new TestService();
const service2 = new TestService();
service1.debouncedMethod();
// Advance partially
vi.advanceTimersByTime(150);
service2.debouncedMethod();
// Advance remaining time for service1
vi.advanceTimersByTime(150);
expect(service1.callCount).toBe(1);
expect(service2.callCount).toBe(0);
// Advance remaining time for service2
vi.advanceTimersByTime(150);
expect(service1.callCount).toBe(1);
expect(service2.callCount).toBe(1);
});
});
describe('Default options', () => {
class TestService {
callCount = 0;
@Debounce()
defaultOptionsMethod(): void {
this.callCount++;
}
@Debounce({})
emptyOptionsMethod(): void {
this.callCount++;
}
}
it('should use default options when none provided', () => {
const service = new TestService();
service.defaultOptionsMethod();
// Default wait is 0, trailing is true
expect(service.callCount).toBe(0);
vi.advanceTimersByTime(0);
expect(service.callCount).toBe(1);
});
it('should use default options when empty options provided', () => {
const service = new TestService();
service.emptyOptionsMethod();
expect(service.callCount).toBe(0);
vi.advanceTimersByTime(0);
expect(service.callCount).toBe(1);
});
});
});

View File

@@ -0,0 +1,96 @@
import { debounce as lodashDebounce } from 'lodash';
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Options for configuring the Debounce decorator
*/
export interface DebounceOptions {
/**
* Number of milliseconds to delay execution.
* @default 0
*/
wait?: number;
/**
* Specify invoking on the leading edge of the timeout.
* @default false
*/
leading?: boolean;
/**
* Specify invoking on the trailing edge of the timeout.
* @default true
*/
trailing?: boolean;
/**
* Maximum time the function is allowed to be delayed before it's invoked.
*/
maxWait?: number;
}
/**
* Decorator that debounces method calls using lodash's debounce function.
* Delays invoking the decorated method until after wait milliseconds have elapsed
* since the last time the debounced method was invoked.
*
* @param options Configuration options for the debounce behavior
* @example
* ```typescript
* class SearchService {
* // Basic debouncing with 300ms delay
* @Debounce({ wait: 300 })
* search(query: string): void {
* console.log('Searching for:', query);
* }
*
* // Debounce with leading edge execution
* @Debounce({ wait: 500, leading: true, trailing: false })
* saveData(data: any): void {
* console.log('Saving:', data);
* }
*
* // With maximum wait time
* @Debounce({ wait: 1000, maxWait: 5000 })
* autoSave(): void {
* console.log('Auto-saving...');
* }
* }
* ```
*/
export function Debounce<T extends (...args: any[]) => any>(
options: DebounceOptions = {},
): MethodDecorator {
const debouncedFunctionMap = new WeakMap<object, ReturnType<typeof lodashDebounce>>();
return function (
_target: any,
_propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = function (this: any, ...args: Parameters<T>): void {
// Get or create debounced function for this instance
if (!debouncedFunctionMap.has(this)) {
const debouncedFn = lodashDebounce(
originalMethod.bind(this),
options.wait ?? 0,
{
leading: options.leading ?? false,
trailing: options.trailing ?? true,
maxWait: options.maxWait,
},
);
debouncedFunctionMap.set(this, debouncedFn);
}
const debouncedFn = debouncedFunctionMap.get(this);
if (debouncedFn) {
debouncedFn(...args);
}
};
return descriptor;
};
}

View File

@@ -0,0 +1,487 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { z } from 'zod';
import { ValidateParams, ValidateParam, ZodValidationError } from './zod-validate.decorator';
describe('Zod Validation Decorators', () => {
describe('ValidateParam Decorator', () => {
class TestService {
callCount = 0;
lastArgs: any[] = [];
@ValidateParam(0, z.string())
singleStringParam(value: string): string {
this.callCount++;
this.lastArgs = [value];
return value.toUpperCase();
}
@ValidateParam(0, z.string().email(), 'email')
emailValidation(email: string): string {
this.callCount++;
this.lastArgs = [email];
return email;
}
@ValidateParam(0, z.string().min(1), 'name')
@ValidateParam(1, z.number().int().positive(), 'age')
multipleValidations(name: string, age: number): string {
this.callCount++;
this.lastArgs = [name, age];
return `${name} is ${age} years old`;
}
}
let service: TestService;
beforeEach(() => {
service = new TestService();
});
it('should validate string parameter successfully', () => {
const result = service.singleStringParam('hello');
expect(result).toBe('HELLO');
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual(['hello']);
});
it('should throw validation error for invalid string', () => {
expect(() => service.singleStringParam(123 as any)).toThrow(ZodValidationError);
try {
service.singleStringParam(123 as any);
} catch (error) {
if (error instanceof ZodValidationError) {
expect(error.parameterIndex).toBe(0);
expect(error.parameterName).toBeUndefined();
expect(error.zodError).toBeInstanceOf(z.ZodError);
expect(error.message).toContain('Parameter validation failed for parameter at index 0');
}
}
expect(service.callCount).toBe(0);
});
it('should validate email parameter successfully', () => {
const email = 'test@example.com';
const result = service.emailValidation(email);
expect(result).toBe(email);
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual([email]);
});
it('should throw validation error for invalid email with custom parameter name', () => {
expect(() => service.emailValidation('invalid-email')).toThrow(ZodValidationError);
try {
service.emailValidation('invalid-email');
} catch (error) {
if (error instanceof ZodValidationError) {
expect(error.parameterIndex).toBe(0);
expect(error.parameterName).toBe('email');
expect(error.message).toContain('Parameter validation failed for parameter "email"');
expect(error.zodError.issues[0].code).toBe('invalid_string');
}
}
expect(service.callCount).toBe(0);
});
it('should validate multiple parameters with chained decorators', () => {
const result = service.multipleValidations('John', 25);
expect(result).toBe('John is 25 years old');
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual(['John', 25]);
});
it('should throw validation error for invalid first parameter with chained decorators', () => {
expect(() => service.multipleValidations('', 25)).toThrow(ZodValidationError);
try {
service.multipleValidations('', 25);
} catch (error) {
if (error instanceof ZodValidationError) {
expect(error.parameterIndex).toBe(0);
expect(error.parameterName).toBe('name');
expect(error.message).toContain('Parameter validation failed for parameter "name"');
}
}
expect(service.callCount).toBe(0);
});
});
describe('ValidateParams Decorator', () => {
interface User {
name: string;
age: number;
email?: string;
}
const UserSchema = z.object({
name: z.string().min(1),
age: z.number().int().min(0).max(120),
email: z.string().email().optional(),
});
class UserService {
callCount = 0;
lastArgs: any[] = [];
@ValidateParams([z.string().min(1), z.number().int().positive()])
multipleParams(name: string, age: number): string {
this.callCount++;
this.lastArgs = [name, age];
return `${name} is ${age} years old`;
}
@ValidateParams([UserSchema], ['userData'])
processUser(user: User): string {
this.callCount++;
this.lastArgs = [user];
return `Processing ${user.name}`;
}
@ValidateParams(
[UserSchema, z.boolean().default(false)],
['userData', 'sendWelcomeEmail']
)
createUser(userData: User, sendEmail?: boolean): string {
this.callCount++;
this.lastArgs = [userData, sendEmail];
return `User ${userData.name} created`;
}
@ValidateParams([z.array(z.string().min(1)), z.string().optional()])
processStrings(items: string[], label?: string): string {
this.callCount++;
this.lastArgs = [items, label];
return `${label || 'Items'}: ${items.join(', ')}`;
}
@ValidateParams([z.string().transform(str => str.toUpperCase())])
transformString(input: string): string {
this.callCount++;
this.lastArgs = [input];
return `Transformed: ${input}`;
}
@ValidateParams([z.string().transform(str => new Date(str))])
processDate(dateString: string): string {
this.callCount++;
this.lastArgs = [dateString];
const date = dateString as any; // After transformation, it's a Date
return date.toISOString();
}
}
let service: UserService;
beforeEach(() => {
service = new UserService();
});
it('should validate multiple parameters successfully', () => {
const result = service.multipleParams('John', 25);
expect(result).toBe('John is 25 years old');
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual(['John', 25]);
});
it('should throw validation error for invalid first parameter', () => {
expect(() => service.multipleParams('', 25)).toThrow(ZodValidationError);
try {
service.multipleParams('', 25);
} catch (error) {
if (error instanceof ZodValidationError) {
expect(error.parameterIndex).toBe(0);
expect(error.message).toContain('Parameter validation failed for parameter at index 0');
}
}
expect(service.callCount).toBe(0);
});
it('should throw validation error for invalid second parameter', () => {
expect(() => service.multipleParams('John', -5)).toThrow(ZodValidationError);
try {
service.multipleParams('John', -5);
} catch (error) {
if (error instanceof ZodValidationError) {
expect(error.parameterIndex).toBe(1);
expect(error.message).toContain('Parameter validation failed for parameter at index 1');
}
}
expect(service.callCount).toBe(0);
});
it('should validate valid user object', () => {
const user = { name: 'John', age: 25, email: 'john@example.com' };
const result = service.processUser(user);
expect(result).toBe('Processing John');
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual([user]);
});
it('should validate user object without optional email', () => {
const user = { name: 'Jane', age: 30 };
const result = service.processUser(user);
expect(result).toBe('Processing Jane');
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual([user]);
});
it('should throw validation error for missing required field with custom parameter name', () => {
const invalidUser = { age: 25 } as any;
expect(() => service.processUser(invalidUser)).toThrow(ZodValidationError);
try {
service.processUser(invalidUser);
} catch (error) {
if (error instanceof ZodValidationError) {
expect(error.parameterIndex).toBe(0);
expect(error.parameterName).toBe('userData');
expect(error.message).toContain('Parameter validation failed for parameter "userData"');
expect(error.zodError.issues[0].path).toContain('name');
expect(error.zodError.issues[0].code).toBe('invalid_type');
}
}
expect(service.callCount).toBe(0);
});
it('should handle default values in schemas', () => {
const user = { name: 'John', age: 25 };
const result = service.createUser(user);
expect(result).toBe('User John created');
expect(service.callCount).toBe(1);
// Second parameter should be set to false (default value)
expect(service.lastArgs).toEqual([user, false]);
});
it('should validate array of strings', () => {
const items = ['hello', 'world', 'test'];
const result = service.processStrings(items);
expect(result).toBe('Items: hello, world, test');
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual([items, undefined]);
});
it('should throw validation error for empty string in array', () => {
const items = ['hello', '', 'world'];
expect(() => service.processStrings(items)).toThrow(ZodValidationError);
try {
service.processStrings(items);
} catch (error) {
if (error instanceof ZodValidationError) {
expect(error.parameterIndex).toBe(0);
expect(error.zodError.issues[0].path).toEqual([1]);
expect(error.zodError.issues[0].code).toBe('too_small');
}
}
expect(service.callCount).toBe(0);
});
it('should handle transformations', () => {
const result = service.transformString('hello');
expect(result).toBe('Transformed: HELLO');
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual(['HELLO']);
});
it('should transform string to date', () => {
const dateString = '2023-01-01';
const result = service.processDate(dateString);
expect(result).toBe('2023-01-01T00:00:00.000Z');
expect(service.callCount).toBe(1);
// The argument should be transformed to a Date object
expect(service.lastArgs[0]).toBeInstanceOf(Date);
});
});
describe('Async Methods', () => {
class AsyncService {
callCount = 0;
lastArgs: any[] = [];
@ValidateParam(0, z.string().min(1), 'data')
async processDataAsync(data: string): Promise<string> {
this.callCount++;
this.lastArgs = [data];
return Promise.resolve(`Processed: ${data}`);
}
@ValidateParams([z.string().uuid(), z.boolean().optional()], ['userId', 'includeDetails'])
async fetchUserAsync(id: string, includeDetails?: boolean): Promise<any> {
this.callCount++;
this.lastArgs = [id, includeDetails];
return Promise.resolve({ id, includeDetails });
}
}
let service: AsyncService;
beforeEach(() => {
service = new AsyncService();
});
it('should validate parameters for async methods', async () => {
const result = await service.processDataAsync('test data');
expect(result).toBe('Processed: test data');
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual(['test data']);
});
it('should throw validation error for async methods', async () => {
let caughtError: ZodValidationError | null = null;
try {
await service.processDataAsync('');
} catch (error) {
if (error instanceof ZodValidationError) {
caughtError = error;
}
}
expect(caughtError).not.toBeNull();
expect(caughtError!.parameterIndex).toBe(0);
expect(caughtError!.parameterName).toBe('data');
expect(caughtError!.zodError.issues[0].code).toBe('too_small');
expect(service.callCount).toBe(0);
});
it('should validate UUID parameter with ValidateParams', async () => {
const uuid = '123e4567-e89b-12d3-a456-426614174000';
const result = await service.fetchUserAsync(uuid, true);
expect(result).toEqual({ id: uuid, includeDetails: true });
expect(service.callCount).toBe(1);
expect(service.lastArgs).toEqual([uuid, true]);
});
it('should throw validation error with custom parameter name for async methods', async () => {
let caughtError: ZodValidationError | null = null;
try {
await service.fetchUserAsync('invalid-uuid');
} catch (error) {
if (error instanceof ZodValidationError) {
caughtError = error;
}
}
expect(caughtError).not.toBeNull();
expect(caughtError!.parameterIndex).toBe(0);
expect(caughtError!.parameterName).toBe('userId');
expect(caughtError!.message).toContain('Parameter validation failed for parameter "userId"');
expect(service.callCount).toBe(0);
});
});
describe('Multiple Instances', () => {
class TestService {
public instanceId: number;
public callCount = 0;
constructor(instanceId: number) {
this.instanceId = instanceId;
}
@ValidateParam(0, z.string())
processData(data: string): string {
this.callCount++;
return `Instance ${this.instanceId}: ${data}`;
}
}
it('should maintain separate validation per instance', () => {
const service1 = new TestService(1);
const service2 = new TestService(2);
const result1 = service1.processData('test1');
const result2 = service2.processData('test2');
expect(result1).toBe('Instance 1: test1');
expect(result2).toBe('Instance 2: test2');
expect(service1.callCount).toBe(1);
expect(service2.callCount).toBe(1);
// Both should still validate independently
expect(() => service1.processData(123 as any)).toThrow(ZodValidationError);
expect(() => service2.processData(456 as any)).toThrow(ZodValidationError);
// Call counts should remain the same
expect(service1.callCount).toBe(1);
expect(service2.callCount).toBe(1);
});
});
describe('Edge Cases', () => {
class EdgeCaseService {
@ValidateParams([z.string().optional()])
processUndefined(value?: string): string {
return value || 'default';
}
@ValidateParams([z.string().nullable()])
processNull(value: string | null): string {
return value || 'null';
}
@ValidateParams([z.string(), undefined, z.number().optional()])
skipMiddleValidation(first: string, middle: any, third?: number): string {
return `${first}-${middle}-${third || 0}`;
}
}
let service: EdgeCaseService;
beforeEach(() => {
service = new EdgeCaseService();
});
it('should handle undefined with optional schema', () => {
const result = service.processUndefined(undefined);
expect(result).toBe('default');
});
it('should handle null with nullable schema', () => {
const result = service.processNull(null);
expect(result).toBe('null');
});
it('should validate string with nullable schema', () => {
const result = service.processNull('test');
expect(result).toBe('test');
});
it('should skip validation for undefined schema entries', () => {
const result = service.skipMiddleValidation('hello', { any: 'object' }, 42);
expect(result).toBe('hello-[object Object]-42');
// First parameter should still be validated
expect(() => service.skipMiddleValidation(123 as any, 'anything', 42)).toThrow(ZodValidationError);
// Middle parameter should not be validated (can be anything)
const result2 = service.skipMiddleValidation('hello', 'anything');
expect(result2).toBe('hello-anything-0');
});
});
});

View File

@@ -0,0 +1,240 @@
import { z } from 'zod';
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Error thrown when parameter validation fails
*/
export class ZodValidationError extends Error {
constructor(
public parameterIndex: number,
public parameterName: string | undefined,
public zodError: z.ZodError,
) {
const paramInfo = parameterName ? `"${parameterName}"` : `at index ${parameterIndex}`;
super(`Parameter validation failed for parameter ${paramInfo}: ${zodError.message}`);
this.name = 'ZodValidationError';
}
}
/**
* Method decorator that validates method parameters using Zod schemas.
* Each parameter that needs validation should have a corresponding schema in the schemas array.
*
* @param schemas Array of Zod schemas for validating parameters. Use `undefined` for parameters that don't need validation.
* @param parameterNames Optional array of parameter names for better error messages
* @example
* ```typescript
* class UserService {
* // Basic parameter validation
* @ValidateParams([z.string().email(), z.object({ name: z.string(), age: z.number().min(0) })])
* createUser(email: string, profile: UserProfile) {
* // Method implementation
* }
*
* // With custom parameter names and optional validation
* @ValidateParams(
* [z.string().uuid(), z.object({ name: z.string().min(1) }), undefined],
* ['userId', 'userUpdate', 'options']
* )
* updateUser(id: string, data: Partial<User>, options?: any) {
* // Method implementation
* }
* }
* ```
*/
export function ValidateParams(
schemas: Array<z.ZodSchema<any> | undefined>,
parameterNames?: string[]
): MethodDecorator {
return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
if (typeof originalMethod !== 'function') {
return descriptor;
}
descriptor.value = function (this: any, ...args: any[]) {
// Validate each parameter that has a schema
for (let i = 0; i < schemas.length; i++) {
const schema = schemas[i];
if (schema) {
const paramValue = args[i];
const parameterName = parameterNames?.[i];
try {
// Use safeParse to avoid throwing errors and get detailed error info
const result = schema.safeParse(paramValue);
if (!result.success) {
throw new ZodValidationError(
i,
parameterName,
result.error
);
}
// Replace the argument with the parsed/transformed value
args[i] = result.data;
} catch (error) {
if (error instanceof ZodValidationError) {
throw error;
} else if (error instanceof z.ZodError) {
throw new ZodValidationError(
i,
parameterName,
error
);
} else {
// Re-throw unexpected errors
throw error;
}
}
}
}
// Call the original method with validated parameters
return originalMethod.apply(this, args);
};
// Preserve function name
Object.defineProperty(descriptor.value, 'name', {
value: originalMethod.name,
configurable: true,
});
return descriptor;
};
}
/**
* Parameter decorator that works in combination with ValidateParams.
* This is primarily for TypeScript type checking and IDE support.
* The actual validation is performed by ValidateParams method decorator.
*
* @deprecated Use ValidateParams method decorator instead
* @param schema The Zod schema (for type checking only)
* @param parameterName Parameter name (for type checking only)
*/
export function ZValidate<T>(_schema: z.ZodSchema<T>, _parameterName?: string) {
return function (_target: any, propertyKey: string | symbol, _parameterIndex: number) {
// This is a placeholder decorator that doesn't do anything
// The actual validation should be done with ValidateParams
console.warn(
`ZValidate parameter decorator is deprecated. Use ValidateParams method decorator on ${String(propertyKey)} instead.`
);
};
}
/**
* Convenience function to create a method decorator for a single parameter validation.
*
* @param parameterIndex The index of the parameter to validate (0-based)
* @param schema The Zod schema to validate against
* @param parameterName Optional parameter name for better error messages
* @example
* ```typescript
* class UserService {
* @ValidateParam(0, z.string().email(), 'email')
* processUser(email: string, data: any) {
* // Method implementation
* }
* }
* ```
*/
export function ValidateParam(
parameterIndex: number,
schema: z.ZodSchema<any>,
parameterName?: string
): MethodDecorator {
return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
if (typeof originalMethod !== 'function') {
return descriptor;
}
descriptor.value = function (this: any, ...args: any[]) {
// Validate the specific parameter
if (parameterIndex < args.length) {
const paramValue = args[parameterIndex];
try {
const result = schema.safeParse(paramValue);
if (!result.success) {
throw new ZodValidationError(
parameterIndex,
parameterName,
result.error
);
}
// Replace the argument with the parsed/transformed value
args[parameterIndex] = result.data;
} catch (error) {
if (error instanceof ZodValidationError) {
throw error;
} else if (error instanceof z.ZodError) {
throw new ZodValidationError(
parameterIndex,
parameterName,
error
);
} else {
throw error;
}
}
}
return originalMethod.apply(this, args);
};
// Preserve function name
Object.defineProperty(descriptor.value, 'name', {
value: originalMethod.name,
configurable: true,
});
return descriptor;
};
}
/**
* Options for configuring validation behavior
*/
export interface ZValidateOptions {
/**
* Custom parameter name for error messages
*/
parameterName?: string;
/**
* Whether to throw on validation errors or return validation result
* @default true
*/
throwOnError?: boolean;
}
/**
* Advanced parameter decorator with additional configuration options.
* Provides the same validation as ZValidate but with more control over behavior.
*
* @param schema The Zod schema to validate the parameter against
* @param options Configuration options for validation behavior
* @example
* ```typescript
* class UserService {
* processUser(
* @ZValidateWithOptions(z.string().email(), {
* parameterName: 'userEmail',
* throwOnError: true
* }) email: string
* ) {
* // Method implementation
* }
* }
* ```
*/
export function ZValidateWithOptions<T>(
schema: z.ZodSchema<T>,
options: ZValidateOptions = {}
) {
return ZValidate(schema, options.parameterName);
}

View File

@@ -1,7 +1,517 @@
# core-storage
# @isa/core/storage
This library was generated with [Nx](https://nx.dev).
A powerful, type-safe storage library for Angular applications built on top of NgRx Signals. This library provides seamless integration between NgRx Signal Stores and various storage backends including localStorage, sessionStorage, IndexedDB, and server-side user state.
## Running unit tests
## Features
Run `nx test core-storage` to execute the unit tests.
- 🔄 **Auto-sync with NgRx Signals**: Seamlessly integrates with NgRx Signal Stores
- 🏪 **Multiple Storage Providers**: Support for localStorage, sessionStorage, IndexedDB, memory, and server-side storage
- 🚀 **Auto-save with Debouncing**: Automatically persist state changes with configurable debouncing
- 👤 **User-scoped Storage**: Automatic user-specific storage keys using OAuth identity claims
- 🔒 **Type-safe**: Full TypeScript support with Zod schema validation
- 🎛️ **Configurable**: Flexible configuration options for different use cases
- 🧩 **Extensible**: Easy to add custom storage providers
## Installation
This library is part of the ISA Frontend monorepo and is already available as `@isa/core/storage`.
## Quick Start
```typescript
import { signalStore, withState } from '@ngrx/signals';
import { withStorage, LocalStorageProvider } from '@isa/core/storage';
// Create a store with automatic localStorage persistence
const UserPreferencesStore = signalStore(
withState({ theme: 'dark', language: 'en' }),
withStorage('user-preferences', LocalStorageProvider)
);
// The store will automatically:
// 1. Load saved state on initialization
// 2. Provide manual save/load methods
// 3. Auto-save state changes (if enabled)
```
## Storage Providers
### LocalStorageProvider
Persists data to the browser's localStorage (survives browser restarts).
```typescript
import { LocalStorageProvider } from '@isa/core/storage';
const store = signalStore(
withState({ count: 0 }),
withStorage('counter', LocalStorageProvider)
);
```
### SessionStorageProvider
Persists data to sessionStorage (cleared when tab closes).
```typescript
import { SessionStorageProvider } from '@isa/core/storage';
const store = signalStore(
withState({ tempData: null }),
withStorage('session-data', SessionStorageProvider)
);
```
### IDBStorageProvider
Uses IndexedDB for larger data storage with better performance.
```typescript
import { IDBStorageProvider } from '@isa/core/storage';
const store = signalStore(
withState({ largeDataSet: [] }),
withStorage('large-data', IDBStorageProvider)
);
```
### UserStorageProvider
Server-side storage tied to the authenticated user's account.
```typescript
import { UserStorageProvider } from '@isa/core/storage';
const store = signalStore(
withState({ userSettings: {} }),
withStorage('user-settings', UserStorageProvider)
);
```
### MemoryStorageProvider
In-memory storage for testing or temporary data.
```typescript
import { MemoryStorageProvider } from '@isa/core/storage';
const store = signalStore(
withState({ testData: null }),
withStorage('test-data', MemoryStorageProvider)
);
```
## Configuration Options
The `withStorage` function accepts an optional configuration object:
```typescript
interface WithStorageConfig {
autosave?: boolean; // Enable automatic state persistence (default: false)
debounceTime?: number; // Debounce time in milliseconds (default: 300)
autoload?: boolean; // Enable automatic state loading on initialization (default: true)
}
```
### Manual Save/Load with Auto-load (Default Behavior)
```typescript
const store = signalStore(
withState({ data: 'initial' }),
withStorage('my-data', LocalStorageProvider)
// Default: autoload = true, autosave = false
);
// State is automatically loaded on initialization
// Manual save/load operations available
store.saveToStorage(); // Save current state
store.loadFromStorage(); // Manually reload state
```
### Disable Auto-load for Full Manual Control
```typescript
const store = signalStore(
withState({ data: 'initial' }),
withStorage('my-data', LocalStorageProvider, {
autoload: false // Disable automatic loading
})
);
// Manual operations only
store.saveToStorage(); // Save current state
store.loadFromStorage(); // Load and restore state
```
### Auto-save with Auto-load
```typescript
const store = signalStore(
withState({ count: 0 }),
withStorage('counter', LocalStorageProvider, {
autosave: true
// autoload: true (default) - state loaded on initialization
// Will auto-save with 300ms debounce
})
);
```
### Auto-save with Custom Debouncing
```typescript
const store = signalStore(
withState({ settings: {} }),
withStorage('settings', LocalStorageProvider, {
autosave: true,
autoload: true, // Load saved settings on initialization (default)
debounceTime: 1000 // Save 1 second after last change
})
);
```
## Real-World Examples
### Tab Management with Auto-save
```typescript
// From libs/core/tabs/src/lib/tab.ts
export const TabService = signalStore(
{ providedIn: 'root' },
withDevtools('TabService'),
withStorage('tabs', UserStorageProvider), // Server-side user storage
withState<{ activatedTabId: number | null }>({
activatedTabId: null,
}),
withEntities<Tab>(),
// ... other features
);
```
### Shopping Cart with Auto-persistence
```typescript
const ShoppingCartStore = signalStore(
withState<{ items: CartItem[], total: number }>({
items: [],
total: 0
}),
withStorage('shopping-cart', LocalStorageProvider, {
autosave: true,
debounceTime: 500 // Save 500ms after changes stop
}),
withMethods((store) => ({
addItem(item: CartItem) {
const items = [...store.items(), item];
const total = items.reduce((sum, item) => sum + item.price, 0);
patchState(store, { items, total });
// State automatically saved after 500ms
},
removeItem(id: string) {
const items = store.items().filter(item => item.id !== id);
const total = items.reduce((sum, item) => sum + item.price, 0);
patchState(store, { items, total });
// State automatically saved after 500ms
}
}))
);
```
### User Preferences with Manual Control
```typescript
const UserPreferencesStore = signalStore(
withState({
theme: 'light' as 'light' | 'dark',
language: 'en',
notifications: true
}),
withStorage('user-preferences', LocalStorageProvider),
withMethods((store) => ({
updateTheme(theme: 'light' | 'dark') {
patchState(store, { theme });
store.saveToStorage(); // Manual save
},
resetToDefaults() {
patchState(store, {
theme: 'light',
language: 'en',
notifications: true
});
store.saveToStorage(); // Manual save
}
}))
);
```
## Advanced Usage
### User-scoped Storage Keys
The library automatically creates user-specific storage keys when using any storage provider:
```typescript
// Internal key generation (user sub: "user123", key: "settings")
// Results in: "user123:a1b2c3" (where a1b2c3 is a hash of "settings")
```
This ensures that different users' data never conflicts, even on shared devices.
### Schema Validation with Zod
The underlying `Storage` class supports optional Zod schema validation:
```typescript
import { z } from 'zod';
import { injectStorage, LocalStorageProvider } from '@isa/core/storage';
const UserSchema = z.object({
name: z.string(),
age: z.number()
});
// In a service or component
const storage = injectStorage(LocalStorageProvider);
// Type-safe get with validation
const userData = storage.get('user', UserSchema);
// userData is properly typed as z.infer<typeof UserSchema>
```
### Custom Storage Provider
Create your own storage provider by implementing the `StorageProvider` interface:
```typescript
import { Injectable } from '@angular/core';
import { StorageProvider } from '@isa/core/storage';
@Injectable({ providedIn: 'root' })
export class CustomStorageProvider implements StorageProvider {
async init?(): Promise<void> {
// Optional initialization logic
}
async reload?(): Promise<void> {
// Optional reload logic
}
set(key: string, value: unknown): void {
// Your storage implementation
console.log(`Saving ${key}:`, value);
}
get(key: string): unknown {
// Your retrieval implementation
console.log(`Loading ${key}`);
return null;
}
clear(key: string): void {
// Your clear implementation
console.log(`Clearing ${key}`);
}
}
```
## API Reference
### `withStorage(storageKey, storageProvider, config?)`
NgRx Signals store feature that adds storage capabilities.
**Parameters:**
- `storageKey: string` - Unique key for storing data
- `storageProvider: Type<StorageProvider>` - Storage provider class
- `config?: WithStorageConfig` - Optional configuration
**Returns:**
- `SignalStoreFeature` with added methods:
- `saveToStorage()` - Manually save current state
- `loadFromStorage()` - Manually load and apply stored state
### `injectStorage(storageProvider)`
Injectable function to get a storage instance.
**Parameters:**
- `storageProvider: Type<StorageProvider>` - Storage provider class
**Returns:**
- `Storage` instance with methods:
- `set<T>(key, value)` - Store value
- `get<T>(key, schema?)` - Retrieve value with optional validation
- `clear(key)` - Remove value
### Storage Providers
All storage providers implement the `StorageProvider` interface:
```typescript
interface StorageProvider {
init?(): Promise<void>; // Optional initialization
reload?(): Promise<void>; // Optional reload
set(key: string, value: unknown): void; // Store value
get(key: string): unknown; // Retrieve value
clear(key: string): void; // Remove value
}
```
**Available Providers:**
- `LocalStorageProvider` - Browser localStorage
- `SessionStorageProvider` - Browser sessionStorage
- `IDBStorageProvider` - IndexedDB
- `UserStorageProvider` - Server-side user storage
- `MemoryStorageProvider` - In-memory storage
## Best Practices
### 1. Choose the Right Storage Provider
- **LocalStorageProvider**: User preferences, settings that should persist across sessions
- **SessionStorageProvider**: Temporary data that should be cleared when tab closes
- **IDBStorageProvider**: Large datasets, complex objects, better performance needs
- **UserStorageProvider**: Cross-device synchronization, server-backed user data
- **MemoryStorageProvider**: Testing, temporary data during app lifecycle
### 2. Configure Auto-save and Auto-load Wisely
```typescript
// For frequent changes (like form inputs)
withStorage('form-draft', LocalStorageProvider, {
autosave: true,
autoload: true, // Restore draft on page reload
debounceTime: 1000 // Longer debounce
})
// For infrequent changes (like settings)
withStorage('user-settings', LocalStorageProvider, {
autosave: true,
autoload: true, // Load saved settings immediately
debounceTime: 100 // Shorter debounce
})
// For critical data that needs manual control
withStorage('important-data', LocalStorageProvider, {
autoload: false // Disable automatic loading
})
// Use manual saveToStorage() and loadFromStorage() for precise control
```
### 3. Handle Storage Errors
Storage operations can fail (quota exceeded, network issues, etc.). The library handles errors gracefully:
- Failed saves are logged to console but don't throw
- Failed loads return undefined/null
- State continues to work in memory even if storage fails
### 4. Consider Storage Size Limits
- **localStorage/sessionStorage**: ~5-10MB per domain
- **IndexedDB**: Much larger, varies by browser and device
- **UserStorageProvider**: Depends on server configuration
### 5. Test with Different Storage Providers
Use `MemoryStorageProvider` in tests for predictable, isolated behavior:
```typescript
// In tests
const testStore = signalStore(
withState({ data: 'test' }),
withStorage('test-key', MemoryStorageProvider)
);
```
## Architecture Notes
The library consists of several key components:
1. **Storage Class**: Core storage abstraction with user-scoping
2. **StorageProvider Interface**: Pluggable storage backends
3. **withStorage Feature**: NgRx Signals integration
4. **Hash Utilities**: Efficient key generation
5. **User Token**: OAuth-based user identification
The architecture promotes:
- **Separation of Concerns**: Storage logic separate from business logic
- **Type Safety**: Full TypeScript support throughout
- **Extensibility**: Easy to add new storage providers
- **User Privacy**: Automatic user-scoping prevents data leaks
- **Performance**: Debounced saves prevent excessive writes
## Migration Guide
### From Manual localStorage
```typescript
// Before
localStorage.setItem('settings', JSON.stringify(settings));
const settings = JSON.parse(localStorage.getItem('settings') || '{}');
// After
const SettingsStore = signalStore(
withState(defaultSettings),
withStorage('settings', LocalStorageProvider, { autosave: true })
);
```
### From Custom Storage Services
```typescript
// Before
@Injectable()
class SettingsService {
private settings = signal(defaultSettings);
save() {
localStorage.setItem('settings', JSON.stringify(this.settings()));
}
load() {
const data = localStorage.getItem('settings');
if (data) this.settings.set(JSON.parse(data));
}
}
// After
const SettingsStore = signalStore(
withState(defaultSettings),
withStorage('settings', LocalStorageProvider, { autosave: true })
);
```
## Troubleshooting
### Common Issues
1. **Storage not persisting**: Check if storage provider supports your environment
2. **Data not loading**: Verify storage key consistency
3. **Performance issues**: Adjust debounce time or switch storage providers
4. **User data conflicts**: Ensure USER_SUB token is properly configured
### Debug Mode
The storage feature uses the centralized `@isa/core/logging` system. All storage operations are logged with appropriate context including the storage key, autosave settings, and operation details.
To see debug logs, configure the logging system at the application level:
```typescript
// The storage feature automatically logs:
// - Debug: Successful operations, state loading/saving
// - Warn: Validation failures, data type issues
// - Error: Storage failures, fallback application errors
const store = signalStore(
withState({ data: null }),
withStorage('my-data', LocalStorageProvider)
);
// All operations will be logged with context: { module: 'storage', storageKey: 'my-data', ... }
```
## Testing
Run unit tests with:
```bash
nx test core-storage
```
## License
This library is part of the ISA Frontend project and follows the project's licensing terms.

View File

@@ -1,4 +1,5 @@
import { Type } from '@angular/core';
import { Type, effect, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
getState,
patchState,
@@ -6,27 +7,266 @@ import {
withHooks,
withMethods,
} from '@ngrx/signals';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { z } from 'zod';
import { logger } from '@isa/core/logging';
import { StorageProvider } from './storage-providers';
import { injectStorage } from './storage';
export function withStorage(
export interface WithStorageConfig<T = object> {
autosave?: boolean; // default: false
debounceTime?: number; // default: 300
autoload?: boolean; // default: true
validator?: (data: unknown) => data is T; // Custom validation function
schema?: z.ZodType<T>; // Zod schema for validation
fallbackState?: Partial<T>; // Fallback state when validation fails
excludeProperties?: string[]; // Properties to exclude from storage
}
export function withStorage<T extends object>(
storageKey: string,
storageProvider: Type<StorageProvider>,
config: WithStorageConfig<T> = {},
) {
// Input validation
if (
!storageKey ||
typeof storageKey !== 'string' ||
storageKey.trim() === ''
) {
throw new Error(`Invalid storage key: ${storageKey}`);
}
if (!storageProvider) {
throw new Error('Storage provider is required');
}
const {
autosave = false,
debounceTime: debounceTimeMs = 300,
autoload = true,
validator,
schema,
fallbackState,
excludeProperties = [],
} = config;
// Validate configuration
if (debounceTimeMs < 0) {
throw new Error('Debounce time must be non-negative');
}
return signalStoreFeature(
withMethods((store, storage = injectStorage(storageProvider)) => ({
storeState: () => storage.set(storageKey, getState(store)),
restoreState: async () => {
const data = await storage.get(storageKey);
if (data && typeof data === 'object') {
patchState(store, data);
}
withMethods(
(
store,
storage = injectStorage(storageProvider),
log = logger(() => ({
module: 'storage',
storageKey,
autosave,
autoload,
debounceTime: debounceTimeMs,
})),
) => ({
saveToStorage: () => {
try {
const state = getState(store);
// Filter out excluded properties if specified
const filteredState =
excludeProperties.length > 0
? Object.fromEntries(
Object.entries(state).filter(
([key]) => !excludeProperties.includes(key),
),
)
: state;
storage.set(storageKey, filteredState);
log.debug('Successfully saved state');
} catch (error) {
log.error(
'Failed to save state',
error instanceof Error ? error : undefined,
);
throw new Error(
`Storage save failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
},
loadFromStorage: () => {
try {
const data = storage.get(storageKey);
if (!data) {
log.debug('No data found in storage');
if (fallbackState && Object.keys(fallbackState).length > 0) {
patchState(store, fallbackState);
log.debug('Applied fallback state');
}
return;
}
// Enhanced validation
if (
typeof data !== 'object' ||
data === null ||
Array.isArray(data)
) {
log.warn('Invalid data type in storage', () => ({
dataType: typeof data,
}));
if (fallbackState && Object.keys(fallbackState).length > 0) {
patchState(store, fallbackState);
log.debug('Applied fallback state due to invalid data type');
}
return;
}
// Zod schema validation
if (schema) {
try {
const validatedData = schema.parse(data);
patchState(store, validatedData);
log.debug('Successfully loaded and validated state');
return;
} catch (validationError) {
log.warn('Schema validation failed', () => ({
validationError,
}));
if (fallbackState && Object.keys(fallbackState).length > 0) {
patchState(store, fallbackState);
log.debug(
'Applied fallback state due to schema validation failure',
);
}
return;
}
}
// Custom validator function
if (validator && !validator(data)) {
log.warn('Custom validation failed');
if (fallbackState && Object.keys(fallbackState).length > 0) {
patchState(store, fallbackState);
log.debug(
'Applied fallback state due to custom validation failure',
);
}
return;
}
// Basic validation passed - apply state
patchState(store, data as Partial<T>);
log.debug('Successfully loaded state');
} catch (error) {
log.error(
'Failed to load state',
error instanceof Error ? error : undefined,
);
if (fallbackState && Object.keys(fallbackState).length > 0) {
try {
patchState(store, fallbackState);
log.debug('Applied fallback state due to load error');
} catch (fallbackError) {
log.error(
'Failed to apply fallback state',
fallbackError instanceof Error ? fallbackError : undefined,
);
}
}
// Don't throw here as we want the app to continue working even if load fails
}
},
}),
),
withHooks(
(
store,
log = logger(() => ({
module: 'storage',
storageKey,
autosave,
autoload,
debounceTime: debounceTimeMs,
})),
) => {
let cleanup: (() => void) | null = null;
return {
onInit() {
// Load initial state if autoload is enabled
if (autoload) {
try {
store.loadFromStorage();
} catch (error) {
log.error(
'Failed to load initial state',
error instanceof Error ? error : undefined,
);
}
}
if (autosave) {
const destroyRef = inject(DestroyRef);
const saveSubject = new Subject<void>();
const subscription = saveSubject
.pipe(
debounceTime(debounceTimeMs),
takeUntilDestroyed(destroyRef),
)
.subscribe(() => {
try {
store.saveToStorage();
} catch (error) {
log.error(
'Autosave failed',
error instanceof Error ? error : undefined,
);
// Don't rethrow - keep autosave running
}
});
const effectRef = effect(() => {
try {
getState(store);
saveSubject.next();
} catch (error) {
log.error(
'Effect error in autosave',
error instanceof Error ? error : undefined,
);
// Don't rethrow - keep effect running
}
});
// Set up comprehensive cleanup
cleanup = () => {
if (!subscription.closed) {
subscription.unsubscribe();
}
if (!saveSubject.closed) {
saveSubject.complete();
}
effectRef.destroy();
};
// Register cleanup with DestroyRef
destroyRef.onDestroy(() => {
cleanup?.();
});
}
},
onDestroy() {
// Additional cleanup hook
cleanup?.();
},
};
},
})),
withHooks((store) => ({
onInit() {
store.restoreState();
},
})),
),
);
}

View File

@@ -1,102 +1,96 @@
import { inject, Injectable, resource, ResourceStatus } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { StorageProvider } from './storage-provider';
import { UserStateService } from '@generated/swagger/isa-api';
import { firstValueFrom } from 'rxjs';
import { filter, firstValueFrom, retry, switchMap, tap, timer } from 'rxjs';
import { USER_SUB } from '../tokens';
import { Debounce, ValidateParam } from '@isa/common/decorators';
import z from 'zod';
type UserState = Record<string, unknown>;
@Injectable({ providedIn: 'root' })
export class UserStorageProvider implements StorageProvider {
#userStateService = inject(UserStateService);
#userSub = inject(USER_SUB);
#userSub = toObservable(inject(USER_SUB));
#userStateResource = resource<UserState, void>({
params: () => this.#userSub(),
loader: async () => {
try {
const res = await firstValueFrom(
this.#userStateService.UserStateGetUserState(),
);
if (res?.result?.content) {
return JSON.parse(res.result.content);
}
} catch (error) {
console.error('Error loading user state:', error);
#loadUserState = this.#userSub.pipe(
filter((sub) => sub !== 'anonymous'),
switchMap(() =>
this.#userStateService.UserStateGetUserState().pipe(
retry({
count: 3,
delay: (error, retryCount) => {
console.warn(
`Retrying to load user state, attempt #${retryCount}`,
error,
);
return timer(1000 * retryCount); // Exponential backoff with timer
},
}),
),
),
tap((res) => {
if (res?.result?.content) {
this.#state = JSON.parse(res.result.content);
}
return {};
},
defaultValue: {},
});
}),
);
#setState(state: UserState) {
this.#userStateResource.set(state);
this.#postNewState(state);
#state: UserState = {};
async init() {
await firstValueFrom(this.#loadUserState);
}
#postNewState(state: UserState) {
async reload(): Promise<void> {
await firstValueFrom(this.#loadUserState);
}
#setCurrentState(state: UserState) {
const newState = structuredClone(state);
Object.freeze(newState);
this.#state = newState;
}
#setState(state: UserState) {
this.#setCurrentState(state);
this.postNewState();
}
@Debounce({ wait: 1000 })
private postNewState(): void {
firstValueFrom(
this.#userStateService.UserStateSetUserState({
content: JSON.stringify(state),
content: JSON.stringify(this.#state),
}),
).catch((error) => {
console.error('Error saving user state:', error);
});
}
async init() {
await this.#userStateInitialized();
}
@ValidateParam(0, z.string().min(1))
set(key: string, value: Record<string, unknown>): void {
console.log('Setting user state key:', key, value);
const current = this.#userStateResource.value();
const current = this.#state;
const content = structuredClone(current);
content[key] = value;
this.#setState(content);
}
@ValidateParam(0, z.string().min(1))
get(key: string): unknown {
console.log('Getting user state key:', key);
return this.#userStateResource.value()[key];
return structuredClone(this.#state[key]);
}
@ValidateParam(0, z.string().min(1))
clear(key: string): void {
const current = this.#userStateResource.value();
const current = this.#state;
if (key in current) {
const content = structuredClone(current);
delete content[key];
this.#setState(content);
}
}
reload(): Promise<void> {
this.#userStateResource.reload();
const reloadPromise = new Promise<void>((resolve) => {
const check = setInterval(() => {
if (!this.#userStateResource.isLoading()) {
clearInterval(check);
resolve();
}
}, 100);
});
return reloadPromise;
}
#userStateInitialized() {
return new Promise<ResourceStatus>((resolve) => {
const check = setInterval(() => {
if (
this.#userStateResource.status() === 'resolved' ||
this.#userStateResource.status() === 'error'
) {
clearInterval(check);
resolve(this.#userStateResource.status());
}
}, 100);
});
}
}

View File

@@ -1,12 +1,12 @@
import { inject, InjectionToken } from '@angular/core';
import { inject, InjectionToken, signal, Signal } from '@angular/core';
import { OAuthService } from 'angular-oauth2-oidc';
export const USER_SUB = new InjectionToken<() => string>(
export const USER_SUB = new InjectionToken<Signal<string>>(
'core.storage.user-sub',
{
factory: () => {
const auth = inject(OAuthService, { optional: true });
return () => auth?.getIdentityClaims()?.['sub'] ?? 'anonymous';
return signal(auth?.getIdentityClaims()?.['sub'] ?? 'anonymous');
},
},
);

View File

@@ -64,15 +64,20 @@ export type TabMetadata = z.infer<typeof TabMetadataSchema>;
* Allows individual tabs to override global history limits and behavior.
* Uses passthrough() to preserve other metadata properties not defined here.
*/
export const TabMetadataWithHistorySchema = z.object({
/** Override for maximum history size (1-1000 entries) */
maxHistorySize: z.number().min(1).max(1000).optional(),
/** Override for maximum forward history (0-100 entries) */
maxForwardHistory: z.number().min(0).max(100).optional(),
}).passthrough().default({});
export const TabMetadataWithHistorySchema = z
.object({
/** Override for maximum history size (1-1000 entries) */
maxHistorySize: z.number().min(1).max(1000).optional(),
/** Override for maximum forward history (0-100 entries) */
maxForwardHistory: z.number().min(0).max(100).optional(),
})
.passthrough()
.default({});
/** TypeScript type for metadata with history configuration */
export type TabMetadataWithHistory = z.infer<typeof TabMetadataWithHistorySchema>;
export type TabMetadataWithHistory = z.infer<
typeof TabMetadataWithHistorySchema
>;
/**
* Schema for tab tags (array of strings).
@@ -94,7 +99,7 @@ export const TabSchema = z.object({
/** Unique identifier for the tab */
id: z.number(),
/** Display name for the tab (minimum 1 character) */
name: z.string().min(1),
name: z.string().default('Neuer Vorgang'),
/** Creation timestamp (milliseconds since epoch) */
createdAt: z.number(),
/** Last activation timestamp (optional) */
@@ -159,22 +164,24 @@ export interface TabCreate {
* Ensures tabs loaded from sessionStorage/localStorage have all required
* properties with strict validation (no extra properties allowed).
*/
export const PersistedTabSchema = z.object({
/** Required unique identifier */
id: z.number(),
/** Tab display name */
name: z.string().min(1),
/** Creation timestamp */
createdAt: z.number(),
/** Last activation timestamp */
activatedAt: z.number().optional(),
/** Custom metadata */
metadata: TabMetadataSchema,
/** Navigation history */
location: TabLocationHistorySchema,
/** Organization tags */
tags: TabTagsSchema,
}).strict();
export const PersistedTabSchema = z
.object({
/** Required unique identifier */
id: z.number(),
/** Tab display name */
name: z.string().default('Neuer Vorgang'),
/** Creation timestamp */
createdAt: z.number(),
/** Last activation timestamp */
activatedAt: z.number().optional(),
/** Custom metadata */
metadata: TabMetadataSchema,
/** Navigation history */
location: TabLocationHistorySchema,
/** Organization tags */
tags: TabTagsSchema,
})
.strict();
/** Input type for TabSchema (before validation) */
export type TabInput = z.input<typeof TabSchema>;
@@ -187,7 +194,7 @@ export type TabInput = z.input<typeof TabSchema>;
*/
export const AddTabSchema = z.object({
/** Display name for the new tab */
name: z.string().min(1),
name: z.string().default('Neuer Vorgang'),
/** Initial tags for the tab */
tags: TabTagsSchema,
/** Initial metadata for the tab */
@@ -210,18 +217,20 @@ export type AddTabInput = z.input<typeof AddTabSchema>;
* Defines optional properties that can be updated on existing tabs.
* All properties are optional to support partial updates.
*/
export const TabUpdateSchema = z.object({
/** Updated display name */
name: z.string().min(1).optional(),
/** Updated activation timestamp */
activatedAt: z.number().optional(),
/** Updated metadata object */
metadata: z.record(z.unknown()).optional(),
/** Updated location history */
location: TabLocationHistorySchema.optional(),
/** Updated tags array */
tags: z.array(z.string()).optional(),
}).strict();
export const TabUpdateSchema = z
.object({
/** Updated display name */
name: z.string().min(1).optional(),
/** Updated activation timestamp */
activatedAt: z.number().optional(),
/** Updated metadata object */
metadata: z.record(z.unknown()).optional(),
/** Updated location history */
location: TabLocationHistorySchema.optional(),
/** Updated tags array */
tags: z.array(z.string()).optional(),
})
.strict();
/** TypeScript type for tab updates */
export type TabUpdate = z.infer<typeof TabUpdateSchema>;
@@ -232,10 +241,12 @@ export type TabUpdate = z.infer<typeof TabUpdateSchema>;
* Specifically validates activation timestamp updates when
* switching between tabs.
*/
export const TabActivationUpdateSchema = z.object({
/** New activation timestamp */
activatedAt: z.number(),
}).strict();
export const TabActivationUpdateSchema = z
.object({
/** New activation timestamp */
activatedAt: z.number(),
})
.strict();
/** TypeScript type for activation updates */
export type TabActivationUpdate = z.infer<typeof TabActivationUpdateSchema>;
@@ -245,10 +256,12 @@ export type TabActivationUpdate = z.infer<typeof TabActivationUpdateSchema>;
*
* Validates metadata-only updates to avoid affecting other tab properties.
*/
export const TabMetadataUpdateSchema = z.object({
/** Updated metadata object */
metadata: z.record(z.unknown()),
}).strict();
export const TabMetadataUpdateSchema = z
.object({
/** Updated metadata object */
metadata: z.record(z.unknown()),
})
.strict();
/** TypeScript type for metadata updates */
export type TabMetadataUpdate = z.infer<typeof TabMetadataUpdateSchema>;
@@ -258,10 +271,12 @@ export type TabMetadataUpdate = z.infer<typeof TabMetadataUpdateSchema>;
*
* Validates navigation history updates when tabs navigate to new locations.
*/
export const TabLocationUpdateSchema = z.object({
/** Updated location history */
location: TabLocationHistorySchema,
}).strict();
export const TabLocationUpdateSchema = z
.object({
/** Updated location history */
location: TabLocationHistorySchema,
})
.strict();
/** TypeScript type for location updates */
export type TabLocationUpdate = z.infer<typeof TabLocationUpdateSchema>;

View File

@@ -4,6 +4,7 @@ import { NavigationEnd, Router, UrlTree } from '@angular/router';
import { filter } from 'rxjs/operators';
import { TabService } from './tab';
import { TabLocation } from './schemas';
import { Title } from '@angular/platform-browser';
/**
* Service that automatically syncs browser navigation events to tab location history.
@@ -26,7 +27,7 @@ import { TabLocation } from './schemas';
export class TabNavigationService {
#router = inject(Router);
#tabService = inject(TabService);
#document = inject(DOCUMENT);
#title = inject(Title);
constructor() {
this.#initializeNavigationSync();
@@ -87,35 +88,7 @@ export class TabNavigationService {
}
#getPageTitle(): string {
// Try document title first
if (this.#document.title && this.#document.title !== 'ISA') {
return this.#document.title;
}
// Fallback to extracting from URL or using generic title
const urlSegments = this.#router.url
.split('/')
.filter((segment) => segment);
const lastSegment = urlSegments[urlSegments.length - 1];
switch (lastSegment) {
case 'dashboard':
return 'Dashboard';
case 'product':
return 'Produktkatalog';
case 'customer':
return 'Kundensuche';
case 'cart':
return 'Warenkorb';
case 'order':
return 'Kundenbestellungen';
default:
return lastSegment ? this.#capitalizeFirst(lastSegment) : 'Seite';
}
}
#capitalizeFirst(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
return this.#title.getTitle();
}
#isLocationInHistory(

View File

@@ -2,28 +2,34 @@ import { ResolveFn } from '@angular/router';
import { TabService } from './tab';
import { Tab } from './schemas';
import { inject } from '@angular/core';
import { TabNavigationService } from './tab-navigation.service';
import { logger } from '@isa/core/logging';
export const tabResolverFn: ResolveFn<Tab> = (route) => {
const id = parseInt(route.params['tabId']);
const log = logger(() => ({
context: 'tabResolverFn',
url: route.url.map((s) => s.path).join('/'),
params: JSON.stringify(route.params),
queryParams: JSON.stringify(route.queryParams),
}));
const tabId = parseInt(route.params['tabId']);
const tabService = inject(TabService);
const navigationService = inject(TabNavigationService);
let tab = tabService.entityMap()[id];
if (!tabId || isNaN(tabId) || Number.MAX_SAFE_INTEGER < tabId) {
log.error('Invalid tabId', { tabId });
throw new Error('Invalid tabId');
}
let tab = tabService.entityMap()[tabId];
if (!tab) {
tab = tabService.addTab({
id: tabId,
name: 'Neuer Vorgang',
});
}
tabService.activateTab(tab.id);
// Sync current route to tab location history
setTimeout(() => {
navigationService.syncCurrentRoute();
}, 0);
return tab;
};
@@ -32,6 +38,5 @@ export const processResolverFn: ResolveFn<Tab> = async (route) => {
const tabService = inject(TabService);
await new Promise((resolve) => setTimeout(resolve, 0));
const id = parseInt(route.params['tabId']);
return tabService.entityMap()[id];
};

View File

@@ -2,13 +2,11 @@ import {
patchState,
signalStore,
withComputed,
withHooks,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import {
addEntities,
addEntity,
removeEntity,
updateEntity,
@@ -22,25 +20,32 @@ import {
Tab,
TabLocation,
TabLocationHistory,
PersistedTabSchema,
} from './schemas';
import { TAB_CONFIG } from './tab-config';
import { TabHistoryPruner } from './tab-history-pruning';
import { computed, effect, inject } from '@angular/core';
import { computed, inject } from '@angular/core';
import { withDevtools } from '@angular-architects/ngrx-toolkit';
import { CORE_TAB_ID_GENERATOR } from './tab-id.generator';
import { withStorage, UserStorageProvider } from '@isa/core/storage';
export const TabService = signalStore(
{ providedIn: 'root' },
withDevtools('TabService'),
withStorage('tabs', UserStorageProvider, { autosave: true }),
withState<{ activatedTabId: number | null }>({
activatedTabId: null,
}),
withEntities<Tab>(),
withProps((_, idGenerator = inject(CORE_TAB_ID_GENERATOR), config = inject(TAB_CONFIG)) => ({
_generateId: idGenerator,
_config: config,
})),
withProps(
(
_,
idGenerator = inject(CORE_TAB_ID_GENERATOR),
config = inject(TAB_CONFIG),
) => ({
_generateId: idGenerator,
_config: config,
}),
),
withComputed((store) => ({
activatedTab: computed<Tab | null>(() => {
const activeTabId = store.activatedTabId();
@@ -106,13 +111,15 @@ export const TabService = signalStore(
// First, limit forward history if configured
const maxForwardHistory =
(currentTab.metadata as any)?.maxForwardHistory ?? store._config.maxForwardHistory;
(currentTab.metadata as any)?.maxForwardHistory ??
store._config.maxForwardHistory;
const { locations: limitedLocations } = TabHistoryPruner.pruneForwardHistory(
currentLocation.locations,
currentLocation.current,
maxForwardHistory
);
const { locations: limitedLocations } =
TabHistoryPruner.pruneForwardHistory(
currentLocation.locations,
currentLocation.current,
maxForwardHistory,
);
// Add new location
const newLocations: TabLocation[] = [
@@ -129,12 +136,14 @@ export const TabService = signalStore(
const pruningResult = TabHistoryPruner.pruneHistory(
newLocationHistory,
store._config,
currentTab.metadata as any
currentTab.metadata as any,
);
if (pruningResult.entriesRemoved > 0) {
if (store._config.logPruning) {
console.log(`Tab ${id}: Pruned ${pruningResult.entriesRemoved} entries using ${pruningResult.strategy} strategy`);
console.log(
`Tab ${id}: Pruned ${pruningResult.entriesRemoved} entries using ${pruningResult.strategy} strategy`,
);
}
newLocationHistory = {
@@ -144,13 +153,16 @@ export const TabService = signalStore(
}
// Validate index integrity
const { index: validatedCurrent, wasInvalid } = TabHistoryPruner.validateLocationIndex(
newLocationHistory.locations,
newLocationHistory.current
);
const { index: validatedCurrent, wasInvalid } =
TabHistoryPruner.validateLocationIndex(
newLocationHistory.locations,
newLocationHistory.current,
);
if (wasInvalid && store._config.enableIndexValidation) {
console.warn(`Tab ${id}: Invalid location index corrected from ${newLocationHistory.current} to ${validatedCurrent}`);
console.warn(
`Tab ${id}: Invalid location index corrected from ${newLocationHistory.current} to ${validatedCurrent}`,
);
newLocationHistory.current = validatedCurrent;
}
@@ -168,10 +180,11 @@ export const TabService = signalStore(
const currentLocation = currentTab.location;
// Validate current index before navigation
const { index: validatedCurrent } = TabHistoryPruner.validateLocationIndex(
currentLocation.locations,
currentLocation.current
);
const { index: validatedCurrent } =
TabHistoryPruner.validateLocationIndex(
currentLocation.locations,
currentLocation.current,
);
if (validatedCurrent <= 0) return null;
@@ -195,13 +208,13 @@ export const TabService = signalStore(
const currentLocation = currentTab.location;
// Validate current index before navigation
const { index: validatedCurrent } = TabHistoryPruner.validateLocationIndex(
currentLocation.locations,
currentLocation.current
);
const { index: validatedCurrent } =
TabHistoryPruner.validateLocationIndex(
currentLocation.locations,
currentLocation.current,
);
if (validatedCurrent >= currentLocation.locations.length - 1)
return null;
if (validatedCurrent >= currentLocation.locations.length - 1) return null;
const newCurrent = validatedCurrent + 1;
const nextLocation = currentLocation.locations[newCurrent];
@@ -235,13 +248,16 @@ export const TabService = signalStore(
const currentLocation = currentTab.location;
// Validate current index
const { index: validatedCurrent, wasInvalid } = TabHistoryPruner.validateLocationIndex(
currentLocation.locations,
currentLocation.current
);
const { index: validatedCurrent, wasInvalid } =
TabHistoryPruner.validateLocationIndex(
currentLocation.locations,
currentLocation.current,
);
if (wasInvalid && store._config.enableIndexValidation) {
console.warn(`Tab ${id}: Invalid location index corrected in getCurrentLocation from ${currentLocation.current} to ${validatedCurrent}`);
console.warn(
`Tab ${id}: Invalid location index corrected in getCurrentLocation from ${currentLocation.current} to ${validatedCurrent}`,
);
// Correct the invalid index in store
const changes: Partial<Tab> = {
@@ -253,7 +269,10 @@ export const TabService = signalStore(
patchState(store, updateEntity({ id, changes }));
}
if (validatedCurrent < 0 || validatedCurrent >= currentLocation.locations.length) {
if (
validatedCurrent < 0 ||
validatedCurrent >= currentLocation.locations.length
) {
return null;
}
@@ -290,27 +309,4 @@ export const TabService = signalStore(
return updatedLocation;
},
})),
withHooks((store) => ({
onInit() {
const entitiesStr = sessionStorage.getItem('TabEntities');
if (entitiesStr) {
const entities = JSON.parse(entitiesStr);
const validatedEntities = z.array(PersistedTabSchema).parse(entities);
const tabEntities: Tab[] = validatedEntities.map(entity => ({
id: entity.id,
name: entity.name,
createdAt: entity.createdAt,
activatedAt: entity.activatedAt,
metadata: entity.metadata,
location: entity.location,
tags: entity.tags,
}));
patchState(store, addEntities(tabEntities));
}
effect(() => {
const state = store.entities();
sessionStorage.setItem('TabEntities', JSON.stringify(state));
});
},
})),
);

View File

@@ -1,224 +1,224 @@
import {
patchState,
signalStore,
withComputed,
withHooks,
withMethods,
withProps,
} from '@ngrx/signals';
import {
withEntities,
setAllEntities,
updateEntity,
} from '@ngrx/signals/entities';
import { IDBStorageProvider, withStorage } from '@isa/core/storage';
import { computed, effect, inject } from '@angular/core';
import { TabService } from '@isa/core/tabs';
import { Receipt, ReceiptItem, ReturnProcess } from '../models';
import {
CreateReturnProcessError,
CreateReturnProcessErrorReason,
} from '../errors/return-process';
import { logger } from '@isa/core/logging';
import { canReturnReceiptItem } from '../helpers/return-process';
import { ProductCategory } from '../questions';
/**
* Interface representing the parameters required to start a return process.
*/
export type StartProcess = {
processId: number;
returns: {
receipt: Receipt;
items: {
receiptItem: ReceiptItem;
quantity: number;
category: ProductCategory;
}[];
}[];
};
/**
* Store for managing return process entities.
*
* This store is responsible for handling the state and behavior of return process entities used in the application.
* It leverages persistence with the IDBStorageProvider, supports entity management operations, and includes computed
* properties and hooks to synchronize state based on external dependencies.
*
* Key Features:
* - Entity Management: Maintains return process entities using methods to add, update, and remove them.
* - Persistence: Automatically stores state changes via the configured IDBStorageProvider.
* - Computed Properties: Includes a computed "nextId" for generating new unique entity identifiers.
* - Process Actions: Provides methods to handle various actions on return process entities, such as:
* - removeAllEntitiesByProcessId: Removes entities not matching specified process IDs.
* - setAnswer and removeAnswer: Manage answers associated with specific questions for entities.
* - setProductCategory: Assigns a product category to an entity.
* - startProcess: Initializes a new return process by filtering receipt items, validating them, and creating a new set of entities.
*
* Hooks:
* - onInit: Ensures that any entities with process IDs not recognized by the ProcessService are automatically cleaned up.
*
* Exceptions:
* - Throws a NoReturnableItemsError if no returnable items are identified.
* - Throws a MismatchReturnableItemsError if the number of returnable items does not match the expected count.
*/
export const ReturnProcessStore = signalStore(
{ providedIn: 'root' },
withStorage('return-process', IDBStorageProvider),
withEntities<ReturnProcess>(),
withProps(() => ({
_logger: logger(() => ({
store: 'ReturnProcessStore',
})),
})),
withComputed((store) => ({
nextId: computed(() => Math.max(0, ...store.ids().map(Number)) + 1),
})),
withMethods((store) => ({
/**
* Removes all entities associated with the specified process IDs from the store.
* @param processIds - The process IDs to filter entities by.
* @returns void
*/
removeAllEntitiesByProcessId: (...processIds: number[]) => {
const entitiesToRemove = store
.entities()
.filter(
(entity) =>
!processIds.includes(entity.processId) && !entity.returnReceipt,
);
patchState(store, setAllEntities(entitiesToRemove));
store.storeState();
},
/**
* Sets an answer for a specific question associated with an entity.
* @param id - The ID of the entity to update.
* @param question - The question associated with the answer.
* @param answer - The answer to set for the specified question.
* @returns void
*/
setAnswer: <T>(id: number, question: string, answer: T) => {
const entity = store.entityMap()[id];
if (entity && !entity.returnReceipt) {
const answers = { ...entity.answers, [question]: answer };
patchState(
store,
updateEntity({ id: entity.id, changes: { answers } }),
);
store.storeState();
}
},
/**
* Removes an answer for a specific question associated with an entity.
* @param id - The ID of the entity to update.
* @param question - The question associated with the answer to remove.
* @returns void
*/
removeAnswer: (id: number, question: string) => {
const entity = store.entityMap()[id];
if (entity && !entity.returnReceipt) {
const answers = { ...entity.answers };
delete answers[question];
patchState(
store,
updateEntity({ id: entity.id, changes: { answers } }),
);
store.storeState();
}
},
})),
withMethods((store) => ({
/**
* Initializes a new return process by removing previous entities for the given process id,
* then filtering and validating the receipt items, and finally creating new return process entities.
*
* @param params - The configuration for starting a new return process.
* @param params.processId - The unique identifier for the return process.
* @param params.receipt - The associated receipt.
* @param params.items - An array of receipt items to be processed.
*
* @throws {CreateReturnProcessError} Throws an error if no returnable items are found.
* @throws {CreateReturnProcessError} Throws an error if the number of returnable items does not match the total items.
*/
startProcess: (params: StartProcess) => {
// Remove existing entities related to the process to start fresh.
store.removeAllEntitiesByProcessId(params.processId);
const entities: ReturnProcess[] = [];
const nextId = store.nextId();
const returnableItems = params.returns
.flatMap((r) => r.items)
.map((item) => item.receiptItem)
.filter(canReturnReceiptItem);
if (returnableItems.length === 0) {
const err = new CreateReturnProcessError(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
params,
);
store._logger.error(err.message, err);
throw err;
}
if (
returnableItems.length !== params.returns.flatMap((r) => r.items).length
) {
const err = new CreateReturnProcessError(
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS,
params,
);
store._logger.error(err.message, err);
throw err;
}
for (const { receipt, items } of params.returns) {
for (const item of items) {
entities.push({
id: nextId + entities.length,
processId: params.processId,
receiptId: receipt.id,
productCategory: item.category,
quantity: item.quantity,
receiptDate: receipt.printedDate,
receiptItem: item.receiptItem,
answers: {},
});
}
}
patchState(store, setAllEntities(entities));
store.storeState();
},
finishProcess: (returnReceipts: { [id: number]: Receipt }) => {
const entities = store.entities().map((entity, i) => {
const receipt = returnReceipts[i];
if (receipt) {
return { ...entity, returnReceipt: receipt };
}
return entity;
});
patchState(store, setAllEntities(entities));
store.storeState();
},
})),
withHooks((store, tabService = inject(TabService)) => ({
/**
* Lifecycle hook that runs when the store is initialized.
* Sets up an effect to clean up orphaned entities that are no longer associated with active processes.
*/
onInit() {
effect(() => {
const tabIds = tabService.ids();
const orphanedEntity = store
.entities()
.find((entity) => !tabIds.includes(entity.processId));
if (orphanedEntity) {
store.removeAllEntitiesByProcessId(orphanedEntity.processId);
}
});
},
})),
);
import {
patchState,
signalStore,
withComputed,
withHooks,
withMethods,
withProps,
} from '@ngrx/signals';
import {
withEntities,
setAllEntities,
updateEntity,
} from '@ngrx/signals/entities';
import { IDBStorageProvider, withStorage } from '@isa/core/storage';
import { computed, effect, inject } from '@angular/core';
import { TabService } from '@isa/core/tabs';
import { Receipt, ReceiptItem, ReturnProcess } from '../models';
import {
CreateReturnProcessError,
CreateReturnProcessErrorReason,
} from '../errors/return-process';
import { logger } from '@isa/core/logging';
import { canReturnReceiptItem } from '../helpers/return-process';
import { ProductCategory } from '../questions';
/**
* Interface representing the parameters required to start a return process.
*/
export type StartProcess = {
processId: number;
returns: {
receipt: Receipt;
items: {
receiptItem: ReceiptItem;
quantity: number;
category: ProductCategory;
}[];
}[];
};
/**
* Store for managing return process entities.
*
* This store is responsible for handling the state and behavior of return process entities used in the application.
* It leverages persistence with the IDBStorageProvider, supports entity management operations, and includes computed
* properties and hooks to synchronize state based on external dependencies.
*
* Key Features:
* - Entity Management: Maintains return process entities using methods to add, update, and remove them.
* - Persistence: Automatically stores state changes via the configured IDBStorageProvider.
* - Computed Properties: Includes a computed "nextId" for generating new unique entity identifiers.
* - Process Actions: Provides methods to handle various actions on return process entities, such as:
* - removeAllEntitiesByProcessId: Removes entities not matching specified process IDs.
* - setAnswer and removeAnswer: Manage answers associated with specific questions for entities.
* - setProductCategory: Assigns a product category to an entity.
* - startProcess: Initializes a new return process by filtering receipt items, validating them, and creating a new set of entities.
*
* Hooks:
* - onInit: Ensures that any entities with process IDs not recognized by the ProcessService are automatically cleaned up.
*
* Exceptions:
* - Throws a NoReturnableItemsError if no returnable items are identified.
* - Throws a MismatchReturnableItemsError if the number of returnable items does not match the expected count.
*/
export const ReturnProcessStore = signalStore(
{ providedIn: 'root' },
withStorage('return-process', IDBStorageProvider),
withEntities<ReturnProcess>(),
withProps(() => ({
_logger: logger(() => ({
store: 'ReturnProcessStore',
})),
})),
withComputed((store) => ({
nextId: computed(() => Math.max(0, ...store.ids().map(Number)) + 1),
})),
withMethods((store) => ({
/**
* Removes all entities associated with the specified process IDs from the store.
* @param processIds - The process IDs to filter entities by.
* @returns void
*/
removeAllEntitiesByProcessId: (...processIds: number[]) => {
const entitiesToRemove = store
.entities()
.filter(
(entity) =>
!processIds.includes(entity.processId) && !entity.returnReceipt,
);
patchState(store, setAllEntities(entitiesToRemove));
store.saveToStorage();
},
/**
* Sets an answer for a specific question associated with an entity.
* @param id - The ID of the entity to update.
* @param question - The question associated with the answer.
* @param answer - The answer to set for the specified question.
* @returns void
*/
setAnswer: <T>(id: number, question: string, answer: T) => {
const entity = store.entityMap()[id];
if (entity && !entity.returnReceipt) {
const answers = { ...entity.answers, [question]: answer };
patchState(
store,
updateEntity({ id: entity.id, changes: { answers } }),
);
store.saveToStorage();
}
},
/**
* Removes an answer for a specific question associated with an entity.
* @param id - The ID of the entity to update.
* @param question - The question associated with the answer to remove.
* @returns void
*/
removeAnswer: (id: number, question: string) => {
const entity = store.entityMap()[id];
if (entity && !entity.returnReceipt) {
const answers = { ...entity.answers };
delete answers[question];
patchState(
store,
updateEntity({ id: entity.id, changes: { answers } }),
);
store.saveToStorage();
}
},
})),
withMethods((store) => ({
/**
* Initializes a new return process by removing previous entities for the given process id,
* then filtering and validating the receipt items, and finally creating new return process entities.
*
* @param params - The configuration for starting a new return process.
* @param params.processId - The unique identifier for the return process.
* @param params.receipt - The associated receipt.
* @param params.items - An array of receipt items to be processed.
*
* @throws {CreateReturnProcessError} Throws an error if no returnable items are found.
* @throws {CreateReturnProcessError} Throws an error if the number of returnable items does not match the total items.
*/
startProcess: (params: StartProcess) => {
// Remove existing entities related to the process to start fresh.
store.removeAllEntitiesByProcessId(params.processId);
const entities: ReturnProcess[] = [];
const nextId = store.nextId();
const returnableItems = params.returns
.flatMap((r) => r.items)
.map((item) => item.receiptItem)
.filter(canReturnReceiptItem);
if (returnableItems.length === 0) {
const err = new CreateReturnProcessError(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
params,
);
store._logger.error(err.message, err);
throw err;
}
if (
returnableItems.length !== params.returns.flatMap((r) => r.items).length
) {
const err = new CreateReturnProcessError(
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS,
params,
);
store._logger.error(err.message, err);
throw err;
}
for (const { receipt, items } of params.returns) {
for (const item of items) {
entities.push({
id: nextId + entities.length,
processId: params.processId,
receiptId: receipt.id,
productCategory: item.category,
quantity: item.quantity,
receiptDate: receipt.printedDate,
receiptItem: item.receiptItem,
answers: {},
});
}
}
patchState(store, setAllEntities(entities));
store.saveToStorage();
},
finishProcess: (returnReceipts: { [id: number]: Receipt }) => {
const entities = store.entities().map((entity, i) => {
const receipt = returnReceipts[i];
if (receipt) {
return { ...entity, returnReceipt: receipt };
}
return entity;
});
patchState(store, setAllEntities(entities));
store.saveToStorage();
},
})),
withHooks((store, tabService = inject(TabService)) => ({
/**
* Lifecycle hook that runs when the store is initialized.
* Sets up an effect to clean up orphaned entities that are no longer associated with active processes.
*/
onInit() {
effect(() => {
const tabIds = tabService.ids();
const orphanedEntity = store
.entities()
.find((entity) => !tabIds.includes(entity.processId));
if (orphanedEntity) {
store.removeAllEntitiesByProcessId(orphanedEntity.processId);
}
});
},
})),
);

View File

@@ -4,11 +4,19 @@ import { ReturnSearchService } from '../services';
import { of, throwError } from 'rxjs';
import { ListResponseArgs } from '@isa/common/data-access';
import { ReceiptListItem } from '../models';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { TabService } from '@isa/core/tabs';
import { SessionStorageProvider } from '@isa/core/storage';
describe('ReturnSearchStore', () => {
const createService = createServiceFactory({
service: ReturnSearchStore,
mocks: [ReturnSearchService],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
],
mocks: [ReturnSearchService, TabService, SessionStorageProvider],
});
it('should create the store', () => {

View File

@@ -1,278 +1,278 @@
import {
patchState,
signalStore,
type,
withHooks,
withMethods,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import {
addEntity,
entityConfig,
setAllEntities,
updateEntity,
withEntities,
} from '@ngrx/signals/entities';
import { pipe, switchMap, tap } from 'rxjs';
import { ReturnSearchService } from '../services';
import { tapResponse } from '@ngrx/operators';
import { effect, inject } from '@angular/core';
import { QueryTokenSchema } from '../schemas';
import {
Callback,
ListResponseArgs,
takeUntilKeydownEscape,
} from '@isa/common/data-access';
import { ReceiptListItem } from '../models';
import { Query } from '@isa/shared/filter';
import { SessionStorageProvider, withStorage } from '@isa/core/storage';
import { TabService } from '@isa/core/tabs';
/**
* Enum representing the status of a return search process.
*/
export enum ReturnSearchStatus {
Idle = 'idle',
Pending = 'pending',
Success = 'success',
Error = 'error',
}
/**
* Type definition for a return search entity.
*
* @property {number} processId - Unique identifier for the search process
* @property {ReturnSearchStatus} status - Current status of the search process
* @property {ReceiptListItem[]} [items] - List of receipt items returned by the search
* @property {number} [hits] - Total number of results
* @property {string | unknown} [error] - Error details, if any
*/
export type ReturnSearchEntity = {
processId: number;
status: ReturnSearchStatus;
items?: ReceiptListItem[];
hits?: number;
error?: string | unknown;
};
const config = entityConfig({
entity: type<ReturnSearchEntity>(),
selectId: (entity) => entity.processId,
});
/**
* Signal store for managing return search state and operations.
*/
export const ReturnSearchStore = signalStore(
{ providedIn: 'root' },
withStorage('oms-data-access.return-search-store', SessionStorageProvider),
withEntities<ReturnSearchEntity>(config),
withMethods((store) => ({
/**
* Retrieves a return search entity by its process ID.
*
* @param {number} processId - The unique identifier of the search process.
* @returns {ReturnSearchEntity | undefined} The corresponding entity or undefined if not found.
*/
getEntity(processId: number): ReturnSearchEntity | undefined {
return store.entities().find((e) => e.processId === processId);
},
/**
* Removes all entities associated with a specific process ID.
*
* @param {number} processId - The unique identifier of the process whose entities should be removed.
* @returns {void}
*/
removeAllEntitiesByProcessId(processId: number): void {
const entities = store
.entities()
.filter((entity) => entity.processId !== processId);
patchState(store, setAllEntities(entities, config));
},
})),
withMethods((store) => ({
/**
* Prepares the store state before initiating a search operation.
*
* @param {number} processId - The unique identifier of the search process.
* @param {boolean} [clear=true] - Flag indicating whether to clear existing items.
*/
beforeSearch(processId: number, clear = true) {
const entity = store.getEntity(processId);
if (entity) {
let items = entity.items ?? [];
if (clear) {
items = [];
}
patchState(
store,
updateEntity(
{
id: processId,
changes: {
status: ReturnSearchStatus.Pending,
items,
hits: 0,
},
},
config,
),
);
} else {
const entity: ReturnSearchEntity = {
processId,
status: ReturnSearchStatus.Pending,
};
patchState(store, addEntity(entity, config));
}
},
/**
* Handles the success response of a search operation.
*
* @param {Object} options - Options for handling the success response.
* @param {number} options.processId - The unique identifier of the search process.
* @param {ListResponseArgs<ReceiptListItem>} options.response - The search response.
*/
handleSearchSuccess({
processId,
response,
}: {
processId: number;
response: ListResponseArgs<ReceiptListItem>;
}) {
const entityItems = store.getEntity(processId)?.items;
patchState(
store,
updateEntity(
{
id: processId,
changes: {
status: ReturnSearchStatus.Success,
hits: response.hits,
items: entityItems
? [...entityItems, ...response.result]
: response.result,
},
},
config,
),
);
store.storeState();
},
/**
* Handles errors encountered during a search operation.
*
* @param {Object} options - Options for handling the error.
* @param {number} options.processId - The unique identifier of the search process.
* @param {unknown} options.error - The error encountered.
*/
handleSearchError({
processId,
error,
}: {
processId: number;
error: unknown;
}) {
console.error(error);
patchState(
store,
updateEntity(
{
id: processId,
changes: {
items: [],
hits: 0,
status: ReturnSearchStatus.Error,
error,
},
},
config,
),
);
},
handleSearchCompleted(processId: number) {
const entity = store.getEntity(processId);
if (entity?.status !== ReturnSearchStatus.Pending) {
return;
}
patchState(
store,
updateEntity(
{
id: processId, // Assuming we want to update the first entity
changes: {
status: ReturnSearchStatus.Idle,
},
},
config,
),
);
},
})),
withMethods((store, returnSearchService = inject(ReturnSearchService)) => ({
/**
* Initiates a search operation.
*
* @param {Object} options - Options for the search operation.
* @param {number} options.processId - The unique identifier of the search process.
* @param {Query} options.query - The search query parameters.
* @param {Callback<ListResponseArgs<ReceiptListItem>>} [options.cb] - Optional callback for handling the response.
* @param {Record<string, string>} options.params - Search parameters.
*/
search: rxMethod<{
processId: number;
query: Query;
clear: boolean;
cb?: Callback<ListResponseArgs<ReceiptListItem>>;
}>(
pipe(
tap(({ processId, clear }) => store.beforeSearch(processId, clear)),
switchMap(({ processId, query, cb }) =>
returnSearchService
.search(
QueryTokenSchema.parse({
...query,
skip: store.getEntity(processId)?.items?.length ?? 0,
}),
)
.pipe(
takeUntilKeydownEscape(),
tapResponse(
(response) => {
store.handleSearchSuccess({ processId, response });
cb?.({ data: response });
},
(error) => {
store.handleSearchError({ processId, error });
cb?.({ error });
},
() => {
store.handleSearchCompleted(processId);
},
),
),
),
),
),
})),
withHooks((store, tabService = inject(TabService)) => ({
onInit() {
effect(() => {
const tabIds = tabService.ids();
const orphanedEntity = store
.entities()
.find((entity) => !tabIds.includes(entity.processId));
if (orphanedEntity) {
store.removeAllEntitiesByProcessId(orphanedEntity.processId);
}
});
},
})),
);
import {
patchState,
signalStore,
type,
withHooks,
withMethods,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import {
addEntity,
entityConfig,
setAllEntities,
updateEntity,
withEntities,
} from '@ngrx/signals/entities';
import { pipe, switchMap, tap } from 'rxjs';
import { ReturnSearchService } from '../services';
import { tapResponse } from '@ngrx/operators';
import { effect, inject } from '@angular/core';
import { QueryTokenSchema } from '../schemas';
import {
Callback,
ListResponseArgs,
takeUntilKeydownEscape,
} from '@isa/common/data-access';
import { ReceiptListItem } from '../models';
import { Query } from '@isa/shared/filter';
import { SessionStorageProvider, withStorage } from '@isa/core/storage';
import { TabService } from '@isa/core/tabs';
/**
* Enum representing the status of a return search process.
*/
export enum ReturnSearchStatus {
Idle = 'idle',
Pending = 'pending',
Success = 'success',
Error = 'error',
}
/**
* Type definition for a return search entity.
*
* @property {number} processId - Unique identifier for the search process
* @property {ReturnSearchStatus} status - Current status of the search process
* @property {ReceiptListItem[]} [items] - List of receipt items returned by the search
* @property {number} [hits] - Total number of results
* @property {string | unknown} [error] - Error details, if any
*/
export type ReturnSearchEntity = {
processId: number;
status: ReturnSearchStatus;
items?: ReceiptListItem[];
hits?: number;
error?: string | unknown;
};
const config = entityConfig({
entity: type<ReturnSearchEntity>(),
selectId: (entity) => entity.processId,
});
/**
* Signal store for managing return search state and operations.
*/
export const ReturnSearchStore = signalStore(
{ providedIn: 'root' },
withStorage('oms-data-access.return-search-store', SessionStorageProvider),
withEntities<ReturnSearchEntity>(config),
withMethods((store) => ({
/**
* Retrieves a return search entity by its process ID.
*
* @param {number} processId - The unique identifier of the search process.
* @returns {ReturnSearchEntity | undefined} The corresponding entity or undefined if not found.
*/
getEntity(processId: number): ReturnSearchEntity | undefined {
return store.entities().find((e) => e.processId === processId);
},
/**
* Removes all entities associated with a specific process ID.
*
* @param {number} processId - The unique identifier of the process whose entities should be removed.
* @returns {void}
*/
removeAllEntitiesByProcessId(processId: number): void {
const entities = store
.entities()
.filter((entity) => entity.processId !== processId);
patchState(store, setAllEntities(entities, config));
},
})),
withMethods((store) => ({
/**
* Prepares the store state before initiating a search operation.
*
* @param {number} processId - The unique identifier of the search process.
* @param {boolean} [clear=true] - Flag indicating whether to clear existing items.
*/
beforeSearch(processId: number, clear = true) {
const entity = store.getEntity(processId);
if (entity) {
let items = entity.items ?? [];
if (clear) {
items = [];
}
patchState(
store,
updateEntity(
{
id: processId,
changes: {
status: ReturnSearchStatus.Pending,
items,
hits: 0,
},
},
config,
),
);
} else {
const entity: ReturnSearchEntity = {
processId,
status: ReturnSearchStatus.Pending,
};
patchState(store, addEntity(entity, config));
}
},
/**
* Handles the success response of a search operation.
*
* @param {Object} options - Options for handling the success response.
* @param {number} options.processId - The unique identifier of the search process.
* @param {ListResponseArgs<ReceiptListItem>} options.response - The search response.
*/
handleSearchSuccess({
processId,
response,
}: {
processId: number;
response: ListResponseArgs<ReceiptListItem>;
}) {
const entityItems = store.getEntity(processId)?.items;
patchState(
store,
updateEntity(
{
id: processId,
changes: {
status: ReturnSearchStatus.Success,
hits: response.hits,
items: entityItems
? [...entityItems, ...response.result]
: response.result,
},
},
config,
),
);
store.saveToStorage();
},
/**
* Handles errors encountered during a search operation.
*
* @param {Object} options - Options for handling the error.
* @param {number} options.processId - The unique identifier of the search process.
* @param {unknown} options.error - The error encountered.
*/
handleSearchError({
processId,
error,
}: {
processId: number;
error: unknown;
}) {
console.error(error);
patchState(
store,
updateEntity(
{
id: processId,
changes: {
items: [],
hits: 0,
status: ReturnSearchStatus.Error,
error,
},
},
config,
),
);
},
handleSearchCompleted(processId: number) {
const entity = store.getEntity(processId);
if (entity?.status !== ReturnSearchStatus.Pending) {
return;
}
patchState(
store,
updateEntity(
{
id: processId, // Assuming we want to update the first entity
changes: {
status: ReturnSearchStatus.Idle,
},
},
config,
),
);
},
})),
withMethods((store, returnSearchService = inject(ReturnSearchService)) => ({
/**
* Initiates a search operation.
*
* @param {Object} options - Options for the search operation.
* @param {number} options.processId - The unique identifier of the search process.
* @param {Query} options.query - The search query parameters.
* @param {Callback<ListResponseArgs<ReceiptListItem>>} [options.cb] - Optional callback for handling the response.
* @param {Record<string, string>} options.params - Search parameters.
*/
search: rxMethod<{
processId: number;
query: Query;
clear: boolean;
cb?: Callback<ListResponseArgs<ReceiptListItem>>;
}>(
pipe(
tap(({ processId, clear }) => store.beforeSearch(processId, clear)),
switchMap(({ processId, query, cb }) =>
returnSearchService
.search(
QueryTokenSchema.parse({
...query,
skip: store.getEntity(processId)?.items?.length ?? 0,
}),
)
.pipe(
takeUntilKeydownEscape(),
tapResponse(
(response) => {
store.handleSearchSuccess({ processId, response });
cb?.({ data: response });
},
(error) => {
store.handleSearchError({ processId, error });
cb?.({ error });
},
() => {
store.handleSearchCompleted(processId);
},
),
),
),
),
),
})),
withHooks((store, tabService = inject(TabService)) => ({
onInit() {
effect(() => {
const tabIds = tabService.ids();
const orphanedEntity = store
.entities()
.find((entity) => !tabIds.includes(entity.processId));
if (orphanedEntity) {
store.removeAllEntitiesByProcessId(orphanedEntity.processId);
}
});
},
})),
);

View File

@@ -18,3 +18,4 @@ export * from './value-tuple-sting-and-integer';
export * from './create-remission';
export * from './remission-item-source';
export * from './receipt-complete-status';
export * from './remission-response-args-error-message';

View File

@@ -0,0 +1,11 @@
// #5331 - Messages kommen bis auf AlreadyRemoved aus dem Backend
export const RemissionResponseArgsErrorMessage = {
AlreadyCompleted: 'Remission wurde bereits abgeschlossen',
AlreadyRemitted: 'Artikel wurde bereits remittiert',
AlreadyRemoved: 'Artikel konnte nicht entfernt werden',
} as const;
export type RemissionResponseArgsErrorMessageKey =
keyof typeof RemissionResponseArgsErrorMessage;
export type RemissionResponseArgsErrorMessageValue =
(typeof RemissionResponseArgsErrorMessage)[RemissionResponseArgsErrorMessageKey];

View File

@@ -1,295 +1,295 @@
import {
patchState,
signalStore,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { ReturnItem, ReturnSuggestion } from '../models';
import { computed, inject, resource } from '@angular/core';
import { UserStorageProvider, withStorage } from '@isa/core/storage';
import { RemissionReturnReceiptService } from '../services';
/**
* Union type representing items that can be selected for remission.
* Can be either a ReturnItem or a ReturnSuggestion.
*/
export type RemissionItem = ReturnItem | ReturnSuggestion;
/**
* Interface defining the state structure for the remission selection store.
*/
interface RemissionState {
/** The unique identifier for the return process. Can only be set once. */
returnId: number | undefined;
/** The unique identifier for the receipt. Can only be set once. */
receiptId: number | undefined;
/** Map of selected remission items indexed by their ID */
selectedItems: Record<number, RemissionItem>;
/** Map of selected quantities for each remission item indexed by their ID */
selectedQuantity: Record<number, number>;
}
/**
* Initial state for the remission selection store.
* All values are undefined or empty objects.
*/
const initialState: RemissionState = {
returnId: undefined,
receiptId: undefined,
selectedItems: {},
selectedQuantity: {},
};
/**
* NgRx Signal Store for managing remission selection state.
* Provides methods to start remission processes, select items, update quantities,
* and manage the overall selection state.
*
* @example
* ```typescript
* // Inject the store in a component
* readonly remissionStore = inject(RemissionStore);
*
* // Start a remission process
* this.remissionStore.startRemission(123, 456);
*
* // Select an item
* this.remissionStore.selectRemissionItem(1, returnItem);
*
* // Update quantity
* this.remissionStore.updateRemissionQuantity(1, returnItem, 5);
* ```
*/
export const RemissionStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withStorage('remission-data-access.remission-store', UserStorageProvider),
withProps(
(
store,
remissionReturnReceiptService = inject(RemissionReturnReceiptService),
) => ({
/**
* Resource for fetching the receipt data based on the current receiptId.
* This resource is automatically reloaded when the receiptId changes.
* @returnId is undefined, the resource will not fetch any data.
* @returnId is set, it fetches the receipt data from the service.
*/
_fetchReturnResource: resource({
params: () => ({
returnId: store.returnId(),
}),
loader: async ({ params, abortSignal }) => {
const { returnId } = params;
if (!returnId) {
return undefined;
}
const returnData = await remissionReturnReceiptService.fetchReturn(
{
returnId,
},
abortSignal,
);
return returnData;
},
}),
}),
),
withComputed((store) => ({
remissionStarted: computed(
() => store.returnId() !== undefined && store.receiptId() !== undefined,
),
returnData: computed(() => store._fetchReturnResource.value()),
})),
withMethods((store) => ({
/**
* Initializes a remission process with the given return and receipt IDs.
* Can only be called once - subsequent calls will throw an error.
*
* @param returnId - The unique identifier for the return process
* @param receiptId - The unique identifier for the receipt
* @throws {Error} When remission has already been started (returnId or receiptId already set)
*
* @example
* ```typescript
* remissionStore.startRemission(123, 456);
* ```
*/
startRemission({
returnId,
receiptId,
}: {
returnId: number;
receiptId: number;
}) {
if (store.returnId() !== undefined || store.receiptId() !== undefined) {
throw new Error(
'Remission has already been started. returnId and receiptId can only be set once.',
);
}
patchState(store, {
returnId,
receiptId,
});
store._fetchReturnResource.reload();
store.storeState();
},
/**
* Reloads the return resource to fetch the latest data.
* This is useful when the return data might have changed and needs to be refreshed.
*
* @example
* ```typescript
* remissionStore.reloadReturn();
* ```
*/
reloadReturn() {
store._fetchReturnResource.reload();
},
/**
* Checks if the current remission matches the provided returnId and receiptId.
* This is useful for determining if the current remission is active in the context of a component.
*
* @param returnId - The return ID to check against the current remission
* @param receiptId - The receipt ID to check against the current remission
* @returns {boolean} True if the current remission matches the provided IDs, false otherwise
*
* @example
* ```typescript
* const isCurrent = remissionStore.isCurrentRemission(123, 456);
* ```
*/
isCurrentRemission({
returnId,
receiptId,
}: {
returnId: number | undefined;
receiptId: number | undefined;
}): boolean {
return store.returnId() === returnId && store.receiptId() === receiptId;
},
/**
* Selects a remission item and adds it to the selected items collection.
* If the item is already selected, it will be replaced with the new item.
*
* @param remissionItemId - The unique identifier for the remission item
* @param item - The remission item to select (ReturnItem or ReturnSuggestion)
*
* @example
* ```typescript
* const returnItem: ReturnItem = { id: 1, name: 'Product A' };
* remissionStore.selectRemissionItem(1, returnItem);
* ```
*/
selectRemissionItem(remissionItemId: number, item: RemissionItem) {
patchState(store, {
selectedItems: { ...store.selectedItems(), [remissionItemId]: item },
});
},
/**
* Updates the quantity for a selected remission item.
* Also ensures the item is in the selected items collection.
*
* @param remissionItemId - The unique identifier for the remission item
* @param item - The remission item to update (ReturnItem or ReturnSuggestion)
* @param quantity - The new quantity value
*
* @example
* ```typescript
* const returnItem: ReturnItem = { id: 1, name: 'Product A' };
* remissionStore.updateRemissionQuantity(1, returnItem, 5);
* ```
*/
updateRemissionQuantity(
remissionItemId: number,
item: RemissionItem,
quantity: number,
) {
patchState(store, {
selectedItems: { ...store.selectedItems(), [remissionItemId]: item },
selectedQuantity: {
...store.selectedQuantity(),
[remissionItemId]: quantity,
},
});
},
/**
* Removes a remission item from the selected items collection.
* Does not affect the selected quantities.
*
* @param remissionItemId - The unique identifier for the remission item to remove
*
* @example
* ```typescript
* remissionStore.removeItem(1);
* ```
*/
removeItem(remissionItemId: number) {
const items = { ...store.selectedItems() };
delete items[remissionItemId];
patchState(store, {
selectedItems: items,
});
},
/**
* Removes a remission item and its associated quantity from the store.
* Updates both selected items and selected quantities collections.
*
* @param remissionItemId - The unique identifier for the remission item to remove
*
* @example
* ```typescript
* remissionStore.removeItemAndQuantity(1);
* ```
*/
removeItemAndQuantity(remissionItemId: number) {
const items = { ...store.selectedItems() };
const quantities = { ...store.selectedQuantity() };
delete items[remissionItemId];
delete quantities[remissionItemId];
patchState(store, {
selectedItems: items,
selectedQuantity: quantities,
});
},
/**
* Clears all selected remission items.
* Resets the remission state to its initial values.
*
* @example
* ```typescript
* remissionStore.clearSelectedItems();
* ```
*/
clearSelectedItems() {
patchState(store, {
selectedItems: {},
});
},
/**
* Clears the remission store state, resetting all values to their initial state.
* This is useful for starting a new remission process or clearing the current state.
*
* @example
* ```typescript
* remissionStore.clearState();
* ```
*/
clearState() {
patchState(store, initialState);
store.storeState();
},
})),
);
import {
patchState,
signalStore,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { ReturnItem, ReturnSuggestion } from '../models';
import { computed, inject, resource } from '@angular/core';
import { UserStorageProvider, withStorage } from '@isa/core/storage';
import { RemissionReturnReceiptService } from '../services';
/**
* Union type representing items that can be selected for remission.
* Can be either a ReturnItem or a ReturnSuggestion.
*/
export type RemissionItem = ReturnItem | ReturnSuggestion;
/**
* Interface defining the state structure for the remission selection store.
*/
interface RemissionState {
/** The unique identifier for the return process. Can only be set once. */
returnId: number | undefined;
/** The unique identifier for the receipt. Can only be set once. */
receiptId: number | undefined;
/** Map of selected remission items indexed by their ID */
selectedItems: Record<number, RemissionItem>;
/** Map of selected quantities for each remission item indexed by their ID */
selectedQuantity: Record<number, number>;
}
/**
* Initial state for the remission selection store.
* All values are undefined or empty objects.
*/
const initialState: RemissionState = {
returnId: undefined,
receiptId: undefined,
selectedItems: {},
selectedQuantity: {},
};
/**
* NgRx Signal Store for managing remission selection state.
* Provides methods to start remission processes, select items, update quantities,
* and manage the overall selection state.
*
* @example
* ```typescript
* // Inject the store in a component
* readonly remissionStore = inject(RemissionStore);
*
* // Start a remission process
* this.remissionStore.startRemission(123, 456);
*
* // Select an item
* this.remissionStore.selectRemissionItem(1, returnItem);
*
* // Update quantity
* this.remissionStore.updateRemissionQuantity(1, returnItem, 5);
* ```
*/
export const RemissionStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withStorage('remission-data-access.remission-store', UserStorageProvider),
withProps(
(
store,
remissionReturnReceiptService = inject(RemissionReturnReceiptService),
) => ({
/**
* Resource for fetching the receipt data based on the current receiptId.
* This resource is automatically reloaded when the receiptId changes.
* @returnId is undefined, the resource will not fetch any data.
* @returnId is set, it fetches the receipt data from the service.
*/
_fetchReturnResource: resource({
params: () => ({
returnId: store.returnId(),
}),
loader: async ({ params, abortSignal }) => {
const { returnId } = params;
if (!returnId) {
return undefined;
}
const returnData = await remissionReturnReceiptService.fetchReturn(
{
returnId,
},
abortSignal,
);
return returnData;
},
}),
}),
),
withComputed((store) => ({
remissionStarted: computed(
() => store.returnId() !== undefined && store.receiptId() !== undefined,
),
returnData: computed(() => store._fetchReturnResource.value()),
})),
withMethods((store) => ({
/**
* Initializes a remission process with the given return and receipt IDs.
* Can only be called once - subsequent calls will throw an error.
*
* @param returnId - The unique identifier for the return process
* @param receiptId - The unique identifier for the receipt
* @throws {Error} When remission has already been started (returnId or receiptId already set)
*
* @example
* ```typescript
* remissionStore.startRemission(123, 456);
* ```
*/
startRemission({
returnId,
receiptId,
}: {
returnId: number;
receiptId: number;
}) {
if (store.returnId() !== undefined || store.receiptId() !== undefined) {
throw new Error(
'Remission has already been started. returnId and receiptId can only be set once.',
);
}
patchState(store, {
returnId,
receiptId,
});
store._fetchReturnResource.reload();
store.saveToStorage();
},
/**
* Reloads the return resource to fetch the latest data.
* This is useful when the return data might have changed and needs to be refreshed.
*
* @example
* ```typescript
* remissionStore.reloadReturn();
* ```
*/
reloadReturn() {
store._fetchReturnResource.reload();
},
/**
* Checks if the current remission matches the provided returnId and receiptId.
* This is useful for determining if the current remission is active in the context of a component.
*
* @param returnId - The return ID to check against the current remission
* @param receiptId - The receipt ID to check against the current remission
* @returns {boolean} True if the current remission matches the provided IDs, false otherwise
*
* @example
* ```typescript
* const isCurrent = remissionStore.isCurrentRemission(123, 456);
* ```
*/
isCurrentRemission({
returnId,
receiptId,
}: {
returnId: number | undefined;
receiptId: number | undefined;
}): boolean {
return store.returnId() === returnId && store.receiptId() === receiptId;
},
/**
* Selects a remission item and adds it to the selected items collection.
* If the item is already selected, it will be replaced with the new item.
*
* @param remissionItemId - The unique identifier for the remission item
* @param item - The remission item to select (ReturnItem or ReturnSuggestion)
*
* @example
* ```typescript
* const returnItem: ReturnItem = { id: 1, name: 'Product A' };
* remissionStore.selectRemissionItem(1, returnItem);
* ```
*/
selectRemissionItem(remissionItemId: number, item: RemissionItem) {
patchState(store, {
selectedItems: { ...store.selectedItems(), [remissionItemId]: item },
});
},
/**
* Updates the quantity for a selected remission item.
* Also ensures the item is in the selected items collection.
*
* @param remissionItemId - The unique identifier for the remission item
* @param item - The remission item to update (ReturnItem or ReturnSuggestion)
* @param quantity - The new quantity value
*
* @example
* ```typescript
* const returnItem: ReturnItem = { id: 1, name: 'Product A' };
* remissionStore.updateRemissionQuantity(1, returnItem, 5);
* ```
*/
updateRemissionQuantity(
remissionItemId: number,
item: RemissionItem,
quantity: number,
) {
patchState(store, {
selectedItems: { ...store.selectedItems(), [remissionItemId]: item },
selectedQuantity: {
...store.selectedQuantity(),
[remissionItemId]: quantity,
},
});
},
/**
* Removes a remission item from the selected items collection.
* Does not affect the selected quantities.
*
* @param remissionItemId - The unique identifier for the remission item to remove
*
* @example
* ```typescript
* remissionStore.removeItem(1);
* ```
*/
removeItem(remissionItemId: number) {
const items = { ...store.selectedItems() };
delete items[remissionItemId];
patchState(store, {
selectedItems: items,
});
},
/**
* Removes a remission item and its associated quantity from the store.
* Updates both selected items and selected quantities collections.
*
* @param remissionItemId - The unique identifier for the remission item to remove
*
* @example
* ```typescript
* remissionStore.removeItemAndQuantity(1);
* ```
*/
removeItemAndQuantity(remissionItemId: number) {
const items = { ...store.selectedItems() };
const quantities = { ...store.selectedQuantity() };
delete items[remissionItemId];
delete quantities[remissionItemId];
patchState(store, {
selectedItems: items,
selectedQuantity: quantities,
});
},
/**
* Clears all selected remission items.
* Resets the remission state to its initial values.
*
* @example
* ```typescript
* remissionStore.clearSelectedItems();
* ```
*/
clearSelectedItems() {
patchState(store, {
selectedItems: {},
});
},
/**
* Clears the remission store state, resetting all values to their initial state.
* This is useful for starting a new remission process or clearing the current state.
*
* @example
* ```typescript
* remissionStore.clearState();
* ```
*/
clearState() {
patchState(store, initialState);
store.saveToStorage();
},
})),
);

View File

@@ -40,16 +40,18 @@ import {
calculateAvailableStock,
RemissionReturnReceiptService,
getStockToRemit,
RemissionListType,
RemissionResponseArgsErrorMessage,
} from '@isa/remission/data-access';
import { injectDialog } from '@isa/ui/dialog';
import { injectDialog, injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
import { RemissionListType } from '@isa/remission/data-access';
import { RemissionReturnCardComponent } from './remission-return-card/remission-return-card.component';
import { logger } from '@isa/core/logging';
import { RemissionProcessedHintComponent } from './remission-processed-hint/remission-processed-hint.component';
import { RemissionListDepartmentElementsComponent } from './remission-list-department-elements/remission-list-department-elements.component';
import { injectTabId } from '@isa/core/tabs';
import { RemissionListEmptyStateComponent } from './remission-list-empty-state/remission-list-empty-state.component';
import { firstValueFrom } from 'rxjs';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
@@ -118,6 +120,7 @@ export class RemissionListComponent {
activatedTabId = injectTabId();
searchItemToRemitDialog = injectDialog(SearchItemToRemitDialogComponent);
errorDialog = injectFeedbackErrorDialog();
/**
* FilterService instance for managing filter state and queries.
@@ -391,34 +394,50 @@ export class RemissionListComponent {
});
/**
* Effect that handles the case when there are no items in the remission list after a search.
* If the search was triggered by the user, it opens a dialog to search for items to remit.
* If remission has already started, it adds the found items to the remission store and remits them.
* If not, it navigates to the default remission list.
* Effect that handles scenarios where a search yields no results.
* If the search was user-initiated and returned no hits, it opens a dialog
* to allow the user to add a new item to remit.
* If only one hit is found and a remission is started, it selects that item automatically.
* This effect runs whenever the remission or stock resource status changes,
* or when the search term changes.
* It ensures that the user is prompted appropriately based on their actions and the current state of the remission process.
* It also checks if the remission is started or if the list type is 'Abteilung' to determine navigation behavior.
* @see {@link
* https://angular.dev/guide/effects} for more information on Angular effects.
* @remarks This effect uses `untracked` to avoid unnecessary re-evaluations
* when accessing certain signals.
*/
emptySearchResultEffect = effect(() => {
const status = this.remissionResource.status();
const stockStatus = this.inStockResource.status();
const searchTerm: string | undefined = this.searchTerm();
if (status !== 'resolved') {
if (status !== 'resolved' || stockStatus !== 'resolved') {
return;
}
const hasItems = !!this.remissionResource.value()?.result?.length;
if (hasItems || !searchTerm || !this.hasValidSearchTerm()) {
return;
}
this.#store.clearSelectedItems();
untracked(() => {
if (!this.searchTriggeredByUser()) {
const hits = this.hits();
// #5338 - Select item automatically if only one hit after search
if (
!!hits ||
!searchTerm ||
!this.hasValidSearchTerm() ||
!this.searchTriggeredByUser()
) {
if (hits === 1 && this.remissionStarted()) {
this.preselectRemissionItem(this.items()[0]);
}
return;
}
this.searchItemToRemitDialog({
data: {
searchTerm,
isDepartment: this.isDepartment(),
},
}).closed.subscribe(async (result) => {
if (result) {
@@ -432,9 +451,8 @@ export class RemissionListComponent {
} else if (this.isDepartment()) {
return await this.navigateToDefaultRemissionList();
}
this.reloadListAndReturnData();
}
this.reloadListAndReturnData();
});
});
});
@@ -493,17 +511,10 @@ export class RemissionListComponent {
});
}
}
this.remitItemsState.set('success');
this.reloadListAndReturnData();
} catch (error) {
this.#logger.error('Failed to remit items', error);
this.remitItemsError.set(
error instanceof Error
? error.message
: 'Artikel konnten nicht remittiert werden',
);
this.remitItemsState.set('error');
await this.handleRemitItemsError(error);
}
this.#store.clearSelectedItems();
@@ -520,6 +531,62 @@ export class RemissionListComponent {
this.#store.reloadReturn();
}
/**
* Pre-Selects a remission item if it has available stock and can be remitted.
* Updates the remission store with the selected item.
* @param item - The ReturnItem or ReturnSuggestion to select.
* @returns void
*/
preselectRemissionItem(item: RemissionItem) {
if (!!item && item.id) {
const inStock = this.getAvailableStockForItem(item);
const stockToRemit = getStockToRemit({
remissionItem: item,
remissionListType: this.selectedRemissionListType(),
availableStock: inStock,
});
if (inStock > 0 && stockToRemit > 0) {
this.#store.selectRemissionItem(item.id, item);
}
}
}
/**
* Handles errors that occur during the remission of items.
* Logs the error, displays an error dialog, and reloads the list and return data.
* If the error indicates that the remission is already completed, it clears the remission state.
* Sets the stateful button to 'error' to indicate the failure.
* @param error - The error object caught during the remission process.
* @returns A promise that resolves when the error handling is complete.
*/
async handleRemitItemsError(error: any) {
this.#logger.error('Failed to remit items', error);
const errorMessage =
error?.error?.message ??
error?.message ??
'Artikel konnten nicht remittiert werden';
this.remitItemsError.set(errorMessage);
await firstValueFrom(
this.errorDialog({
data: {
errorMessage,
},
}).closed,
);
if (errorMessage === RemissionResponseArgsErrorMessage.AlreadyCompleted) {
this.#store.clearState();
}
this.reloadListAndReturnData();
this.remitItemsState.set('error'); // Stateful-Button auf Error setzen
}
/**
* Navigates to the default remission list based on the current activated tab ID.
* This method is used to redirect the user to the remission list after completing or starting a remission.

View File

@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import {
ReceiptItem,
RemissionResponseArgsErrorMessage,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
@@ -20,6 +21,8 @@ import { IconButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
import { logger } from '@isa/core/logging';
import { injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
/**
* Component for displaying a single receipt item within the remission return receipt details.
@@ -55,6 +58,8 @@ export class RemissionReturnReceiptDetailsItemComponent {
}));
#returnReceiptService = inject(RemissionReturnReceiptService);
errorDialog = injectFeedbackErrorDialog();
/**
* Required input for the receipt item to display.
* Contains product information and quantity details.
@@ -85,7 +90,7 @@ export class RemissionReturnReceiptDetailsItemComponent {
removing = signal(false);
removed = output<ReceiptItem>();
reloadReturn = output<void>();
async remove() {
if (this.removing()) {
@@ -98,10 +103,25 @@ export class RemissionReturnReceiptDetailsItemComponent {
returnId: this.returnId(),
receiptItemId: this.item().id,
});
this.removed.emit(this.item());
} catch (error) {
this.#logger.error('Failed to remove item', error);
await this.handleRemoveItemError(error);
}
this.reloadReturn.emit();
this.removing.set(false);
}
async handleRemoveItemError(error: any) {
this.#logger.error('Failed to remove item', error);
const errorMessage =
error?.error?.message ?? RemissionResponseArgsErrorMessage.AlreadyRemoved;
await firstValueFrom(
this.errorDialog({
data: {
errorMessage,
},
}).closed,
);
}
}

View File

@@ -55,7 +55,7 @@
[removeable]="canRemoveItems()"
[receiptId]="receiptId()"
[returnId]="returnId()"
(removed)="returnResource.reload()"
(reloadReturn)="returnResource.reload()"
></remi-remission-return-receipt-details-item>
@if (!last) {
<hr class="border-isa-neutral-300" />

View File

@@ -15,7 +15,7 @@ import {
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import { QuantityAndReason } from './select-remi-quantity-and-reason.component';
import { QuantityAndReason } from './select-remi-quantity-and-reason-dialog.component';
import { ReturnValue } from '@isa/common/data-access';
import { provideIcons } from '@ng-icons/core';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';

View File

@@ -1,18 +1,14 @@
@if (item()) {
<remi-select-remi-quantity-and-reason></remi-select-remi-quantity-and-reason>
} @else {
<button
class="absolute top-4 right-[1.33rem]"
type="button"
uiTextButton
size="small"
color="subtle"
(click)="close(undefined)"
tabindex="-1"
data-what="button"
data-which="close-dialog"
>
Schließen
</button>
<remi-search-item-to-remit-list></remi-search-item-to-remit-list>
}
<button
class="absolute top-4 right-[1.33rem]"
type="button"
uiTextButton
size="small"
color="subtle"
(click)="close(undefined)"
tabindex="-1"
data-what="button"
data-which="close-dialog"
>
Schließen
</button>
<remi-search-item-to-remit-list></remi-search-item-to-remit-list>

View File

@@ -1,33 +1,23 @@
import {
ChangeDetectionStrategy,
Component,
effect,
isSignal,
linkedSignal,
signal,
Signal,
} from '@angular/core';
import { DialogContentDirective, NumberInputValidation } from '@isa/ui/dialog';
import { Item } from '@isa/catalogue/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionSearch } from '@isa/icons';
import { SearchItemToRemitListComponent } from './search-item-to-remit-list.component';
import { SelectRemiQuantityAndReasonComponent } from './select-remi-quantity-and-reason.component';
import { Validators } from '@angular/forms';
import { ReturnSuggestion, ReturnItem } from '@isa/remission/data-access';
import { ReturnItem } from '@isa/remission/data-access';
export type SearchItemToRemitDialogData = {
searchTerm: string | Signal<string>;
isDepartment: boolean;
};
export type SearchItemToRemitDialogResult =
SearchItemToRemitDialogData extends { isDepartment: infer D }
? D extends true
? ReturnSuggestion
: ReturnItem
: never;
// #5273, #4768 Fix - Nur ReturnItems sind zugelassen und dürfen zur Pflichtremission hinzugefügt werden
export type SearchItemToRemitDialogResult = ReturnItem;
@Component({
selector: 'remi-search-item-to-remit-dialog',
@@ -35,11 +25,7 @@ export type SearchItemToRemitDialogResult =
styleUrls: ['./search-item-to-remit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
imports: [TextButtonComponent, SearchItemToRemitListComponent],
providers: [provideIcons({ isaActionSearch })],
})
export class SearchItemToRemitDialogComponent extends DialogContentDirective<
@@ -51,35 +37,4 @@ export class SearchItemToRemitDialogComponent extends DialogContentDirective<
? this.data.searchTerm()
: this.data.searchTerm,
);
item = signal<Item | undefined>(undefined);
itemEffect = effect(() => {
const item = this.item();
this.dialogRef.updateSize(item ? '36rem' : 'auto');
if (item) {
this.dialog.title.set(`Dieser Artikel steht nicht auf der Remi Liste`);
} else {
this.dialog.title.set(undefined);
}
});
quantityValidators: NumberInputValidation[] = [
{
errorKey: 'required',
inputValidator: Validators.required,
errorText: 'Bitte geben Sie eine Menge an.',
},
{
errorKey: 'min',
inputValidator: Validators.min(1),
errorText: 'Die Menge muss mindestens 1 sein.',
},
{
errorKey: 'max',
inputValidator: Validators.max(1000),
errorText: 'Die Menge darf höchstens 1000 sein.',
},
];
}

View File

@@ -14,7 +14,7 @@
name="isaActionSearch"
color="brand"
(click)="triggerSearch()"
[pending]="searchResource.isLoading()"
[pending]="searchResource.isLoading() || inStockResource.isLoading()"
data-what="button"
data-which="search-submit"
></ui-icon-button>
@@ -34,24 +34,23 @@
<ng-icon size="1.5rem" name="isaOtherInfo"></ng-icon>
</button>
</p>
<div class="overflow-y-auto">
<div class="overflow-y-auto overflow-x-hidden">
@if (searchResource.value()?.result; as items) {
@for (item of items; track item.id) {
@for (item of availableSearchResults(); track item.id) {
@defer {
@let inStock = getAvailableStockForItem(item);
@if (inStock > 0) {
<remi-search-item-to-remit
[item]="item"
[inStock]="inStock"
data-what="list-item"
data-which="search-result"
[attr.data-item-id]="item.id"
></remi-search-item-to-remit>
}
<remi-search-item-to-remit
[item]="item"
[inStock]="getAvailableStockForItem(item)"
data-what="list-item"
data-which="search-result"
[attr.data-item-id]="item.id"
></remi-search-item-to-remit>
}
}
}
@if (!hasItems() && !searchResource.isLoading()) {
@if (
!hasItems() && !searchResource.isLoading() && !inStockResource.isLoading()
) {
<ui-empty-state
class="w-full justify-self-center"
title="Keine Suchergebnisse"

View File

@@ -57,6 +57,14 @@ export class SearchItemToRemitListComponent implements OnInit {
searchParams = signal<SearchByTermInput | undefined>(undefined);
availableSearchResults = computed(() => {
return (
this.searchResource.value()?.result?.filter((item) => {
return this.getAvailableStockForItem(item) > 0;
}) ?? []
);
});
inStockResource = createInStockResource(() => {
return {
itemIds:
@@ -69,7 +77,7 @@ export class SearchItemToRemitListComponent implements OnInit {
inStockResponseValue = computed(() => this.inStockResource.value());
hasItems = computed(() => {
return (this.searchResource.value()?.result?.length ?? 0) > 0;
return (this.availableSearchResults()?.length ?? 0) > 0;
});
stockInfoMap = computed(() => {

View File

@@ -18,7 +18,7 @@
type="button"
uiTextButton
color="strong"
(click)="host.item.set(item())"
(click)="openQuantityAndReasonDialog()"
>
Remimenge auswählen
</button>

View File

@@ -10,6 +10,9 @@ import { ProductInfoComponent } from '@isa/remission/shared/product';
import { TextButtonComponent } from '@isa/ui/buttons';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { injectDialog } from '@isa/ui/dialog';
import { SelectRemiQuantityAndReasonDialogComponent } from './select-remi-quantity-and-reason-dialog.component';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'remi-search-item-to-remit',
@@ -20,6 +23,9 @@ import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.
})
export class SearchItemToRemitComponent {
host = inject(SearchItemToRemitDialogComponent);
quantityAndReasonDialog = injectDialog(
SelectRemiQuantityAndReasonDialogComponent,
);
item = input.required<Item>();
inStock = input.required<number>();
@@ -29,4 +35,22 @@ export class SearchItemToRemitComponent {
productInfoOrientation = computed(() => {
return this.desktopBreakpoint() ? 'vertical' : 'horizontal';
});
async openQuantityAndReasonDialog() {
if (this.item()) {
const dialogRef = this.quantityAndReasonDialog({
title: 'Dieser Artikel steht nicht auf der Remi Liste',
data: {
item: this.item(),
inStock: this.inStock(),
},
width: '36rem',
});
const dialogResult = await firstValueFrom(dialogRef.closed);
if (dialogResult) {
this.host.close(dialogResult);
}
}
}
}

View File

@@ -1,84 +1,94 @@
<p class="text-isa-neutral-600 isa-text-body-1-regular">
Wie viele Exemplare können remittiert werden?
</p>
<div class="flex flex-col gap-4">
@for (
quantityAndReason of quantitiesAndResons();
track $index;
let i = $index
) {
<div class="flex items-center gap-1">
<remi-quantity-and-reason-item
[position]="$index + 1"
[quantityAndReason]="quantityAndReason"
(quantityAndReasonChange)="setQuantityAndReason($index, $event)"
class="flex-1"
data-what="component"
data-which="quantity-reason-item"
[attr.data-position]="$index + 1"
></remi-quantity-and-reason-item>
@if (i > 0) {
<ui-icon-button
type="button"
(click)="removeQuantityReasonItem($index)"
data-what="button"
data-which="remove-quantity"
[attr.data-position]="$index + 1"
name="isaActionClose"
color="neutral"
></ui-icon-button>
}
</div>
}
</div>
<div>
<button
type="button"
class="flex items-center gap-2 -ml-5"
uiTextButton
color="strong"
(click)="addQuantityReasonItem()"
data-what="button"
data-which="add-quantity"
>
<ng-icon name="isaActionPlus" size="1.5rem"></ng-icon>
<div>Menge hinzufügen</div>
</button>
</div>
<div class="text-isa-accent-red isa-text-body-1-regular">
<span>
@if (canReturnErrors(); as errors) {
@for (error of errors; track $index) {
{{ error }}
}
}
</span>
</div>
<div class="grid grid-cols-2 items-center gap-2">
<button
type="button"
color="secondary"
size="large"
uiButton
(click)="host.item.set(undefined)"
data-what="button"
data-which="back"
>
Zurück
</button>
<button
type="button"
color="primary"
size="large"
uiButton
[pending]="canAddToRemiListResource.isLoading()"
[disabled]="canAddToRemiListResource.isLoading() || canReturn() === false"
(click)="addToRemiList()"
data-what="button"
data-which="save-remission"
>
Speichern
</button>
</div>
<remi-product-info
[item]="{
product: data.item.product,
retailPrice: data.item.catalogAvailability.price,
}"
></remi-product-info>
<div class="text-isa-neutral-900 flex flex-row items-center justify-end gap-8">
<span class="isa-text-body-2-regular">Aktueller Bestand</span>
<span class="isa-text-body-2-bold">{{ data.inStock }}x</span>
</div>
<p class="text-isa-neutral-600 isa-text-body-1-regular">
Wie viele Exemplare können remittiert werden?
</p>
<div class="flex flex-col gap-4">
@for (
quantityAndReason of quantitiesAndResons();
track $index;
let i = $index
) {
<div class="flex items-center gap-1">
<remi-quantity-and-reason-item
[position]="$index + 1"
[quantityAndReason]="quantityAndReason"
(quantityAndReasonChange)="setQuantityAndReason($index, $event)"
class="flex-1"
data-what="component"
data-which="quantity-reason-item"
[attr.data-position]="$index + 1"
></remi-quantity-and-reason-item>
@if (i > 0) {
<ui-icon-button
type="button"
(click)="removeQuantityReasonItem($index)"
data-what="button"
data-which="remove-quantity"
[attr.data-position]="$index + 1"
name="isaActionClose"
color="neutral"
></ui-icon-button>
}
</div>
}
</div>
<div>
<button
type="button"
class="flex items-center gap-2 -ml-5"
uiTextButton
color="strong"
(click)="addQuantityReasonItem()"
data-what="button"
data-which="add-quantity"
>
<ng-icon name="isaActionPlus" size="1.5rem"></ng-icon>
<div>Menge hinzufügen</div>
</button>
</div>
<div class="text-isa-accent-red isa-text-body-1-regular">
<span>
@if (canReturnErrors(); as errors) {
@for (error of errors; track $index) {
{{ error }}
}
}
</span>
</div>
<div class="grid grid-cols-2 items-center gap-2">
<button
type="button"
color="secondary"
size="large"
uiButton
(click)="close(undefined)"
data-what="button"
data-which="back"
>
Zurück
</button>
<button
type="button"
color="primary"
size="large"
uiButton
[pending]="canAddToRemiListResource.isLoading()"
[disabled]="canAddToRemiListResource.isLoading() || canReturn() === false"
(click)="addToRemiList()"
data-what="button"
data-which="save-remission"
>
Speichern
</button>
</div>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-flow-row gap-6 h-full;
}

View File

@@ -1,184 +1,196 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
model,
resource,
} from '@angular/core';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { QuantityAndReasonItemComponent } from './quantity-and-reason-item.component';
import {
ButtonComponent,
TextButtonComponent,
IconButtonComponent,
} from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPlus, isaActionClose } from '@isa/icons';
import {
RemissionSearchService,
RemissionStore,
ReturnItem,
ReturnSuggestion,
} from '@isa/remission/data-access';
import { injectFeedbackDialog } from '@isa/ui/dialog';
import { BatchResponseArgs } from '@isa/common/data-access';
export interface QuantityAndReason {
quantity: number;
reason: string;
}
@Component({
selector: 'remi-select-remi-quantity-and-reason',
templateUrl: './select-remi-quantity-and-reason.component.html',
styleUrls: ['./select-remi-quantity-and-reason.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
QuantityAndReasonItemComponent,
TextButtonComponent,
NgIcon,
ButtonComponent,
IconButtonComponent,
],
providers: [provideIcons({ isaActionPlus, isaActionClose })],
})
export class SelectRemiQuantityAndReasonComponent {
#remiService = inject(RemissionSearchService);
#remiStore = inject(RemissionStore);
#feedbackDialog = injectFeedbackDialog();
host = inject(SearchItemToRemitDialogComponent);
initialItem: QuantityAndReason = { quantity: 0, reason: '' };
quantitiesAndResons = model<QuantityAndReason[]>([this.initialItem]);
addQuantityReasonItem(): void {
this.quantitiesAndResons.update((items) => [...items, this.initialItem]);
}
removeQuantityReasonItem(position: number): void {
const currentItems = this.quantitiesAndResons();
if (currentItems.length > 1) {
this.quantitiesAndResons.update((items) =>
items.filter((_, index) => index !== position),
);
}
}
setQuantityAndReason(position: number, qar: QuantityAndReason): void {
this.quantitiesAndResons.update((items) => {
const newItems = [...items];
newItems[position] = qar;
return newItems;
});
}
params = computed(() => {
const items = this.quantitiesAndResons();
const item = this.host.item();
if (!item) {
return [];
}
return items.map((qar) => ({
item,
quantity: qar.quantity,
reason: qar.reason,
}));
});
canAddToRemiListResource = resource({
params: this.params,
loader: async ({ params, abortSignal }) => {
if (
!this.host.item() ||
params.some((p) => !p.reason) ||
params.some((p) => !p.quantity)
) {
return undefined;
}
const maxQuantityErrors = params.filter((p) => !(p.quantity <= 999));
if (maxQuantityErrors.length > 0) {
const errRes: BatchResponseArgs<ReturnItem> = {
completed: false,
error: true,
total: maxQuantityErrors.length,
invalidProperties: {
quantity: 'Die Menge darf maximal 999 sein.',
},
};
return errRes;
}
return this.#remiService.canAddItemToRemiList(params, abortSignal);
},
});
canReturn = computed(() => {
const results = this.canAddToRemiListResource.value();
if (!results) {
return false;
}
if (results.failed && results.failed.length > 0) {
return false;
}
if (
results.successful &&
results.successful.length === this.quantitiesAndResons().length
) {
return true;
}
return false;
});
canReturnErrors = computed(() => {
const results = this.canAddToRemiListResource.value();
if (results?.invalidProperties) {
return Object.values(results.invalidProperties);
}
if (!results?.failed) {
return [];
}
return results.failed.map((item) =>
item.invalidProperties
? Object.values(item.invalidProperties).join(', ')
: [],
) as string[];
});
async addToRemiList() {
const canAddValue = this.canAddToRemiListResource.value();
if (!canAddValue) {
return;
}
if (canAddValue.failed?.length) {
return;
}
// #5273, #4768 Fix - Items dürfen nur zur Pflichtremission hinzugefügt werden
const result: Array<ReturnItem> = await this.#remiService.addToList(
this.params(),
);
this.#feedbackDialog({
data: {
message: this.#remiStore.remissionStarted()
? 'Wurde zum Warenbegleitschein hinzugefügt'
: 'Wurde zur Remi Liste hinzugefügt',
},
});
this.host.close(result);
}
}
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
model,
resource,
} from '@angular/core';
import { QuantityAndReasonItemComponent } from './quantity-and-reason-item.component';
import {
ButtonComponent,
TextButtonComponent,
IconButtonComponent,
} from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPlus, isaActionClose } from '@isa/icons';
import {
RemissionSearchService,
RemissionStore,
ReturnItem,
} from '@isa/remission/data-access';
import { DialogContentDirective, injectFeedbackDialog } from '@isa/ui/dialog';
import { BatchResponseArgs } from '@isa/common/data-access';
import { Item } from '@isa/catalogue/data-access';
import { ProductInfoComponent } from '@isa/remission/shared/product';
export type SelectRemiQuantityAndReasonDialogData = {
item: Item;
inStock: number;
};
export type SelectRemiQuantityAndReasonDialogResult =
| undefined
| Array<ReturnItem>;
export interface QuantityAndReason {
quantity: number;
reason: string;
}
@Component({
selector: 'remi-select-remi-quantity-and-reason-dialog',
templateUrl: './select-remi-quantity-and-reason-dialog.component.html',
styleUrls: ['./select-remi-quantity-and-reason-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
QuantityAndReasonItemComponent,
TextButtonComponent,
NgIcon,
ButtonComponent,
IconButtonComponent,
ProductInfoComponent,
],
providers: [provideIcons({ isaActionPlus, isaActionClose })],
})
export class SelectRemiQuantityAndReasonDialogComponent extends DialogContentDirective<
SelectRemiQuantityAndReasonDialogData,
SelectRemiQuantityAndReasonDialogResult
> {
#remiService = inject(RemissionSearchService);
#remiStore = inject(RemissionStore);
#feedbackDialog = injectFeedbackDialog();
initialItem: QuantityAndReason = { quantity: 0, reason: '' };
quantitiesAndResons = model<QuantityAndReason[]>([this.initialItem]);
addQuantityReasonItem(): void {
this.quantitiesAndResons.update((items) => [...items, this.initialItem]);
}
removeQuantityReasonItem(position: number): void {
const currentItems = this.quantitiesAndResons();
if (currentItems.length > 1) {
this.quantitiesAndResons.update((items) =>
items.filter((_, index) => index !== position),
);
}
}
setQuantityAndReason(position: number, qar: QuantityAndReason): void {
this.quantitiesAndResons.update((items) => {
const newItems = [...items];
newItems[position] = qar;
return newItems;
});
}
params = computed(() => {
const items = this.quantitiesAndResons();
const item = this.data.item;
if (!item) {
return [];
}
return items.map((qar) => ({
item,
quantity: qar.quantity,
reason: qar.reason,
}));
});
canAddToRemiListResource = resource({
params: this.params,
loader: async ({ params, abortSignal }) => {
if (
!this.data.item ||
params.some((p) => !p.reason) ||
params.some((p) => !p.quantity)
) {
return undefined;
}
const maxQuantityErrors = params.filter((p) => !(p.quantity <= 999));
if (maxQuantityErrors.length > 0) {
const errRes: BatchResponseArgs<ReturnItem> = {
completed: false,
error: true,
total: maxQuantityErrors.length,
invalidProperties: {
quantity: 'Die Menge darf maximal 999 sein.',
},
};
return errRes;
}
return this.#remiService.canAddItemToRemiList(params, abortSignal);
},
});
canReturn = computed(() => {
const results = this.canAddToRemiListResource.value();
if (!results) {
return false;
}
if (results.failed && results.failed.length > 0) {
return false;
}
if (
results.successful &&
results.successful.length === this.quantitiesAndResons().length
) {
return true;
}
return false;
});
canReturnErrors = computed(() => {
const results = this.canAddToRemiListResource.value();
if (results?.invalidProperties) {
return Object.values(results.invalidProperties);
}
if (!results?.failed) {
return [];
}
return results.failed.map((item) =>
item.invalidProperties
? Object.values(item.invalidProperties).join(', ')
: [],
) as string[];
});
async addToRemiList() {
const canAddValue = this.canAddToRemiListResource.value();
if (!canAddValue) {
return;
}
if (canAddValue.failed?.length) {
return;
}
// #5273, #4768 Fix - Items dürfen nur zur Pflichtremission hinzugefügt werden
const result: Array<ReturnItem> = await this.#remiService.addToList(
this.params(),
);
this.#feedbackDialog({
data: {
message: this.#remiStore.remissionStarted()
? 'Wurde zum Warenbegleitschein hinzugefügt'
: 'Wurde zur Remi Liste hinzugefügt',
},
});
this.close(result);
}
}

View File

@@ -1,3 +0,0 @@
:host {
@apply grid grid-flow-row gap-6;
}

View File

@@ -0,0 +1,17 @@
<div class="w-full flex flex-col gap-4 items-center justify-center">
<span
class="bg-isa-accent-red rounded-[6.25rem] flex flex-row items-center justify-center p-3"
>
<ng-icon
class="text-isa-white"
size="1.5rem"
name="isaActionClose"
></ng-icon>
</span>
<p
class="isa-text-body-1-bold text-isa-neutral-900"
data-what="error-message"
>
{{ data.errorMessage }}
</p>
</div>

View File

@@ -0,0 +1,56 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import {
FeedbackErrorDialogComponent,
FeedbackErrorDialogData,
} from './feedback-error-dialog.component';
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',
};
const createComponent = createComponentFactory({
component: FeedbackErrorDialogComponent,
imports: [NgIcon],
providers: [
{
provide: DialogRef,
useValue: { close: jest.fn() },
},
{
provide: DIALOG_DATA,
useValue: mockData,
},
{
provide: DialogComponent,
useValue: {},
},
],
});
beforeEach(() => {
spectator = createComponent();
jest.clearAllMocks();
});
it('should create', () => {
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');
});
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');
});
});

View File

@@ -0,0 +1,30 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { DialogContentDirective } from '../dialog-content.directive';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
/**
* Input data for the error message dialog
*/
export interface FeedbackErrorDialogData {
/** The Error message text to display in the dialog */
errorMessage: string;
}
/**
* A simple feedback dialog component that displays an error message and an error icon.
*/
@Component({
selector: 'ui-feedback-error-dialog',
templateUrl: './feedback-error-dialog.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIcon],
providers: [provideIcons({ isaActionClose })],
host: {
'[class]': '["ui-feedback-error-dialog"]',
},
})
export class FeedbackErrorDialogComponent extends DialogContentDirective<
FeedbackErrorDialogData,
void
> {}

View File

@@ -7,6 +7,7 @@ import {
injectTextInputDialog,
injectNumberInputDialog,
injectConfirmationDialog,
injectFeedbackErrorDialog,
} from './injects';
import { MessageDialogComponent } from './message-dialog/message-dialog.component';
import { DialogComponent } from './dialog.component';
@@ -17,6 +18,7 @@ import { TextInputDialogComponent } from './text-input-dialog/text-input-dialog.
import { FeedbackDialogComponent } from './feedback-dialog/feedback-dialog.component';
import { NumberInputDialogComponent } from './number-input-dialog/number-input-dialog.component';
import { ConfirmationDialogComponent } from './confirmation-dialog/confirmation-dialog.component';
import { FeedbackErrorDialogComponent } from './feedback-error-dialog/feedback-error-dialog.component';
// Test component extending DialogContentDirective for testing
@Component({ template: '' })
@@ -290,4 +292,23 @@ describe('Dialog Injects', () => {
expect(injector.get(DIALOG_CONTENT)).toBe(ConfirmationDialogComponent);
});
});
describe('injectFeedbackErrorDialog', () => {
it('should create a dialog injector for FeedbackErrorDialogComponent', () => {
// Act
const openFeedbackErrorDialog = TestBed.runInInjectionContext(() =>
injectFeedbackErrorDialog(),
);
openFeedbackErrorDialog({
data: {
errorMessage: 'Test error message',
},
});
// Assert
const callOptions = mockDialogOpen.mock.calls[0][1];
const injector = callOptions.injector;
expect(injector.get(DIALOG_CONTENT)).toBe(FeedbackErrorDialogComponent);
});
});
});

View File

@@ -21,6 +21,10 @@ import {
ConfirmationDialogComponent,
ConfirmationDialogData,
} from './confirmation-dialog/confirmation-dialog.component';
import {
FeedbackErrorDialogComponent,
FeedbackErrorDialogData,
} from './feedback-error-dialog/feedback-error-dialog.component';
export interface InjectDialogOptions {
/** Optional title override for the dialog */
@@ -173,3 +177,17 @@ export const injectFeedbackDialog = (
classList: ['gap-0'],
...options,
});
/**
* Convenience function that returns a pre-configured FeedbackErrorDialog injector
* @returns A function to open a feedback error dialog
*/
export const injectFeedbackErrorDialog = (
options?: OpenDialogOptions<FeedbackErrorDialogData>,
) =>
injectDialog(FeedbackErrorDialogComponent, {
disableClose: false,
minWidth: '20rem',
classList: ['gap-0'],
...options,
});