Merged PR 1970: feat(stock-info): implement request batching with BatchingResource - The main implementation

Related work items: #5348
This commit is contained in:
Lorenz Hilpert
2025-10-16 11:48:33 +00:00
committed by Nino Righi
parent b5c8dc4776
commit e458542b29
17 changed files with 1140 additions and 228 deletions

View File

@@ -46,6 +46,9 @@ npm run build
npm run build-prod
# Or: npx nx build isa-app --configuration=production
# Build without using Nx cache (for fresh builds)
npx nx build isa-app --skip-nx-cache
# Serve the application with SSL (development server)
npm start
# Or: npx nx serve isa-app --ssl
@@ -55,17 +58,21 @@ npm start
```bash
# Run tests for all libraries except the main app (default command)
npm test
# Or: npx nx run-many -t test --exclude isa-app --skip-cache
# Or: npx nx run-many -t test --exclude isa-app --skip-nx-cache
# Run tests for a specific library (always use --skip-cache for fresh results)
npx nx run <project-name>:test --skip-cache
# Example: npx nx run oms-data-access:test --skip-cache
# Run tests for a specific library (always use --skip-nx-cache for fresh results)
npx nx run <project-name>:test --skip-nx-cache
# Example: npx nx run oms-data-access:test --skip-nx-cache
# Skip Nx cache entirely (important for ensuring fresh builds/tests)
npx nx run <project-name>:test --skip-nx-cache
# Or combine with skip-cache: npx nx run <project-name>:test --skip-nx-cache --skip-nx-cache
# Run a single test file
npx nx run <project-name>:test --testFile=<path-to-test-file> --skip-cache
npx nx run <project-name>:test --testFile=<path-to-test-file> --skip-nx-cache
# Run tests with coverage
npx nx run <project-name>:test --code-coverage --skip-cache
npx nx run <project-name>:test --code-coverage --skip-nx-cache
# Run tests in watch mode
npx nx run <project-name>:test --watch
@@ -233,7 +240,8 @@ npx nx affected:test
### Nx Workflow Optimization
- Always use `npx nx run` pattern for executing specific tasks
- Include `--skip-cache` flag when running tests to ensure fresh results
- Include `--skip-nx-cache` flag when running tests to ensure fresh results
- Use `--skip-nx-cache` to bypass Nx cache entirely for guaranteed fresh builds/tests (important for reliability)
- Use affected commands for CI/CD optimization: `npx nx affected:test`
- Visualize project dependencies: `npx nx graph`
- The default git branch is `develop` (not `main`)
@@ -280,13 +288,17 @@ npx nx affected:test
### Performance and Quality Considerations
- **Bundle Monitoring**: Watch bundle sizes (2MB warning, 5MB error for main bundle)
- **Testing Cache**: Always use `--skip-cache` flag when running tests to ensure reliable results
- **Testing Cache**: Always use `--skip-nx-cache` flag when running tests to ensure reliable results
- **Code Quality**: Pre-commit hooks enforce Prettier formatting and ESLint rules automatically
- **Memory Management**: Clean up subscriptions and use OnPush change detection for optimal performance
### Common Troubleshooting
- **Build Issues**: Check Node version and run `npm install` if encountering module resolution errors
- **Test Failures**: Use `--skip-cache` flag and ensure test isolation (no shared state between tests)
- **Test Failures**: Use `--skip-nx-cache` flag and ensure test isolation (no shared state between tests)
- **Nx Cache Issues**: If you see `existing outputs match the cache, left as is` during build or testing:
- **Option 1**: Run `npx nx reset` to clear the Nx cache completely
- **Option 2**: Use `--skip-nx-cache` flag to bypass Nx cache for a specific command (e.g., `npx nx test <project> --skip-nx-cache`)
- **When to use**: Always use `--skip-nx-cache` when you need guaranteed fresh builds or test results
- **SSL Certificates**: Development server uses SSL - accept certificate warnings in browser for localhost
- **Import Errors**: Verify path aliases in `tsconfig.base.json` and use absolute imports for cross-library dependencies

View File

@@ -1,34 +1,38 @@
import {
ChangeDetectionStrategy,
Component,
input,
linkedSignal,
} from '@angular/core';
import { Item } from '@isa/catalogue/data-access';
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { ProductInfoRedemptionComponent } from '@isa/checkout/shared/product-info';
import { StockInfoComponent } from '@isa/checkout/shared/product-info';
import { RewardListItemSelectComponent } from './reward-list-item-select/reward-list-item-select.component';
@Component({
selector: 'reward-list-item',
templateUrl: './reward-list-item.component.html',
styleUrl: './reward-list-item.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClientRowImports,
ItemRowDataImports,
ProductInfoRedemptionComponent,
StockInfoComponent,
RewardListItemSelectComponent,
],
})
export class RewardListItemComponent {
item = input.required<Item>();
desktopBreakpoint = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]);
productInfoOrientation = linkedSignal(() => {
return this.desktopBreakpoint() ? 'horizontal' : 'vertical';
});
}
import {
ChangeDetectionStrategy,
Component,
input,
linkedSignal,
} from '@angular/core';
import { Item } from '@isa/catalogue/data-access';
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { ProductInfoRedemptionComponent } from '@isa/checkout/shared/product-info';
import { StockInfoComponent } from '@isa/checkout/shared/product-info';
import { RewardListItemSelectComponent } from './reward-list-item-select/reward-list-item-select.component';
@Component({
selector: 'reward-list-item',
templateUrl: './reward-list-item.component.html',
styleUrl: './reward-list-item.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClientRowImports,
ItemRowDataImports,
ProductInfoRedemptionComponent,
StockInfoComponent,
RewardListItemSelectComponent,
],
})
export class RewardListItemComponent {
item = input.required<Item>();
desktopBreakpoint = breakpoint([
Breakpoint.Desktop,
Breakpoint.DesktopL,
Breakpoint.DesktopXL,
]);
productInfoOrientation = linkedSignal(() => {
return this.desktopBreakpoint() ? 'horizontal' : 'vertical';
});
}

View File

@@ -3,9 +3,9 @@
<checkout-product-info-redemption
class="grow"
[item]="itm"
[orientation]="isDesktop() ? 'horizontal' : 'vertical'"
[orientation]="isHorizontal() ? 'horizontal' : 'vertical'"
></checkout-product-info-redemption>
@if (isDesktop()) {
@if (isHorizontal()) {
<checkout-destination-info
[underline]="true"
class="cursor-pointer max-w-[14.25rem] grow-0 shrink-0"
@@ -26,7 +26,7 @@
[(isBusy)]="isBusy"
></checkout-reward-shopping-cart-item-remove-button>
</div>
@if (!isDesktop()) {
@if (!isHorizontal()) {
<checkout-destination-info
[underline]="true"
class="cursor-pointer mt-4 max-w-[14.25rem] grow-0 shrink-0"

View File

@@ -21,7 +21,7 @@ import { TabService } from '@isa/core/tabs';
import { firstValueFrom } from 'rxjs';
import { RewardShoppingCartItemQuantityControlComponent } from './reward-shopping-cart-item-quantity-control.component';
import { RewardShoppingCartItemRemoveButtonComponent } from './reward-shopping-cart-item-remove-button.component';
import { StockResource } from '@isa/remission/data-access';
import { StockInfoResource } from '@isa/remission/data-access';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaOtherInfo } from '@isa/icons';
@@ -42,7 +42,7 @@ import { isaOtherInfo } from '@isa/icons';
RewardShoppingCartItemRemoveButtonComponent,
NgIcon,
],
providers: [StockResource, provideIcons({ isaOtherInfo })],
providers: [],
})
export class RewardShoppingCartItemComponent {
#logger = logger(() => ({ component: 'RewardShoppingCartItemComponent' }));
@@ -56,7 +56,11 @@ export class RewardShoppingCartItemComponent {
isBusy = signal(false);
isDesktop = breakpoint([Breakpoint.Desktop, Breakpoint.DekstopL, Breakpoint.DekstopXL]);
isHorizontal = breakpoint([
Breakpoint.DesktopL,
Breakpoint.DesktopL,
Breakpoint.DesktopXL,
]);
item = input.required<ShoppingCartItem>();

View File

@@ -1,5 +1,9 @@
:host {
@apply grid grid-flow-col desktop-large:grid-cols-[0.75fr,0.5fr] gap-6 text-neutral-900;
@apply grid gap-6 text-neutral-900;
}
:host.horizontal {
@apply grid-cols-[1fr,minmax(0,25rem)];
}
:host.vertical {

View File

@@ -30,6 +30,7 @@
<div
class="flex flex-1 flex-col gap-2"
[class.ml-20]="orientation() === 'vertical'"
[class.max-w-80]="orientation() === 'vertical'"
>
<shared-product-format
[format]="prd.format"

View File

@@ -43,7 +43,13 @@ describe('StockInfoComponent', () => {
expect(component.inStock()).toBe(0);
});
it('should have stockResource defined', () => {
expect(component.stockResource).toBeDefined();
it('should have stockInfoResource ResourceRef defined', () => {
expect(component.stockInfoResource).toBeDefined();
expect(component.stockInfoResource.value).toBeDefined();
expect(component.stockInfoResource.isLoading).toBeDefined();
expect(component.stockInfoResource.hasValue).toBeDefined();
expect(component.stockInfoResource.error).toBeDefined();
expect(component.stockInfoResource.status).toBeDefined();
expect(component.stockInfoResource.reload).toBeDefined();
});
});

View File

@@ -4,9 +4,8 @@ import {
computed,
inject,
input,
resource,
} from '@angular/core';
import { RemissionStockService } from '@isa/remission/data-access';
import { StockInfoResource } from '@isa/remission/data-access';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaFiliale } from '@isa/icons';
import { Item } from '@isa/catalogue/data-access';
@@ -28,29 +27,17 @@ export type StockInfoItem = {
exportAs: 'stockInfo',
})
export class StockInfoComponent {
#stockService = inject(RemissionStockService);
#stockInfoResource = inject(StockInfoResource);
item = input.required<StockInfoItem>();
stockResource = resource({
params: () => this.item().id,
loader: ({ params, abortSignal }) =>
this.#stockService.fetchStockInfos(
{
itemIds: [params],
},
abortSignal,
),
});
itemId = computed(() => this.item().id);
inStock = computed(() => {
if (this.stockResource.hasValue()) {
const stock = this.stockResource.value();
return stock[0]?.inStock ?? 0;
}
readonly stockInfoResource = this.#stockInfoResource.resource(
computed(() => ({ itemId: this.itemId() })),
);
return 0;
});
inStock = computed(() => this.stockInfoResource.value()?.inStock ?? 0);
loading = computed(() => this.stockResource.isLoading() && !this.inStock());
loading = computed(() => this.stockInfoResource.isLoading());
}

View File

@@ -0,0 +1,303 @@
# BatchingResource - Reusable Batching Pattern
## Overview
`BatchingResource` is a generic base class for creating smart batching resources that optimize multiple API requests by collecting params from multiple components and making a single batched request.
## Location
```
libs/common/data-access/src/lib/resources/batching-resource.base.ts
```
## Features
-**Automatic request batching** with configurable window
-**Smart caching** (including empty results to prevent infinite loops)
-**Reference counting** for automatic cleanup
-**Per-item loading/error status** tracking
-**Custom cache keys** support
-**TypeScript generics** for type safety
## How to Use
### 1. Create a Resource Class
Extend `BatchingResource` and implement the required abstract methods:
```typescript
import { Injectable, inject } from '@angular/core';
import { BatchingResource } from '@isa/common/data-access';
@Injectable({ providedIn: 'root' })
export class MyDataResource extends BatchingResource<
number, // TParams: What users pass to resource()
MyApiParams, // TResourceParams: The type of batch API parameters
MyDataType // TResourceValue: The type of results
> {
#myService = inject(MyService);
constructor() {
super(100); // batchWindowMs (optional, defaults to 100ms)
}
// Required: Fetch data from your API
protected fetchFn(params: MyApiParams, signal: AbortSignal) {
return this.#myService.fetchData(params, signal);
}
// Required: Convert params list to API request format
protected buildParams(ids: number[]): MyApiParams {
return { ids };
}
// Required: Extract params from result for cache matching
protected getKeyFromResult(result: MyDataType): number | undefined {
return result.id;
}
// Required: Generate unique cache key from params
protected getCacheKey(id: number): string {
return String(id);
}
// Required: Extract params from resource params for status tracking
protected extractKeysFromParams(params: MyApiParams): number[] {
return params.ids;
}
}
```
### 2. Use in Components
```typescript
import { Component, computed, inject, input } from '@angular/core';
@Component({
selector: 'my-component',
template: `
@if (data.isLoading()) {
<div>Loading...</div>
} @else if (data.error()) {
<div>Error: {{ data.error() }}</div>
} @else {
<div>Data: {{ data.value()?.name }}</div>
}
`,
})
export class MyComponent {
#myDataResource = inject(MyDataResource);
itemId = input.required<number>();
// Automatically batches with other components
data = this.#myDataResource.resource(this.itemId);
// Use computed for derived values
name = computed(() => this.data.value()?.name ?? 'Unknown');
}
```
## API Reference
### Constructor
```typescript
constructor(batchWindowMs?: number) // default: 100
```
### Abstract Methods (Must Implement)
```typescript
// Fetch data from your API
protected abstract fetchFn(
params: TResourceParams,
abortSignal: AbortSignal
): Promise<TResourceValue[]>;
// Convert params list to API request format
protected abstract buildParams(params: TParams[]): TResourceParams;
// Extract params from result for cache matching
protected abstract getKeyFromResult(result: TResourceValue): TParams | undefined;
// Generate unique cache key from params
protected abstract getCacheKey(params: TParams): string;
// Extract params from resource params for status tracking
protected abstract extractKeysFromParams(params: TResourceParams): TParams[];
```
### Methods
#### `resource(params)`
Returns a `BatchingResourceRef` for specific params.
**Parameters:**
- `params: Signal<TParams> | TParams` - The params to fetch (can be a signal or static value)
**Returns:** `BatchingResourceRef<TResourceValue>`
#### `reload()`
Reloads all currently tracked items by clearing cache.
#### `reloadKeys(params: TParams[])`
Reloads specific items by clearing their cache entries.
### BatchingResourceRef
The object returned by `resource()`:
```typescript
{
value: Signal<TResourceValue | undefined>; // The cached result
hasValue: Signal<boolean>; // True if cached (even if empty)
isLoading: Signal<boolean>; // True while loading
error: Signal<unknown>; // Error if failed
status: Signal<ResourceStatus>; // 'idle' | 'loading' | 'resolved' | 'error'
reload: () => void; // Reload this specific item
}
```
## Advanced: Custom Cache Keys
For complex scenarios (like stock info with different warehouse IDs), encode all context in `TParams`:
```typescript
// Define TParams as an object containing all necessary context
export class StockResource extends BatchingResource<
{ itemId: number; warehouseId: string }, // TParams includes all context
FetchStockParams, // TResourceParams
StockInfo // TResourceValue
> {
#stockService = inject(StockService);
constructor() {
super(250);
}
protected fetchFn(params: FetchStockParams, signal: AbortSignal) {
return this.#stockService.fetchStockInfos(params, signal);
}
protected buildParams(
paramsList: { itemId: number; warehouseId: string }[]
): FetchStockParams {
return {
itemIds: paramsList.map(p => p.itemId),
warehouseId: paramsList[0].warehouseId, // All should have same warehouse
};
}
protected getKeyFromResult(stock: StockInfo) {
return {
itemId: stock.itemId,
warehouseId: stock.warehouseId
};
}
protected getCacheKey(params: { itemId: number; warehouseId: string }): string {
return `${params.warehouseId}-${params.itemId}`;
}
protected extractKeysFromParams(params: FetchStockParams) {
return params.itemIds.map(itemId => ({
itemId,
warehouseId: params.warehouseId
}));
}
}
// Then use the complete params object
const stockInfo = stockResource.resource({ itemId: 123, warehouseId: 'WH1' });
```
## How It Works
### Batching Process
1. **Component mounts** → Calls `resource(params)`
2. **Effect registers params** → Adds to pending batch queue
3. **Batch window expires** (default 100ms) → Pending params move to committed
4. **Resource triggers** → Fetches data for all committed params
5. **Results cached** → Both successful and empty results stored
6. **Component unmounts** → Reference count decremented, cleanup if zero
### Infinite Loop Prevention
The key innovation is **caching empty results**:
```typescript
// If API returns no data for params, we still cache it with undefined
if (!hasResult) {
cache.set(cacheKey, undefined);
}
```
This prevents the resource from thinking the params are "uncached" and re-requesting them infinitely.
## Example: Stock Info Resource (Real-World)
```typescript
@Injectable({ providedIn: 'root' })
export class StockInfoResource extends BatchingResource<
number, // TParams - Item IDs
FetchStockParams, // TResourceParams - { itemIds: number[] }
StockInfo // TResourceValue - { itemId: number, inStock: number, ... }
> {
#stockService = inject(RemissionStockService);
constructor() {
super(250); // Wait longer for more batching
}
protected fetchFn(params: FetchStockParams, signal: AbortSignal) {
return this.#stockService.fetchStockInfos(params, signal);
}
protected buildParams(itemIds: number[]): FetchStockParams {
return { itemIds };
}
protected getKeyFromResult(stock: StockInfo): number | undefined {
return stock.itemId;
}
protected getCacheKey(itemId: number): string {
return String(itemId);
}
protected extractKeysFromParams(params: FetchStockParams): number[] {
return params.itemIds;
}
}
// Usage in component
const stockInfo = inject(StockInfoResource).resource(itemId);
const inStock = computed(() => stockInfo.value()?.inStock ?? 0);
```
## Benefits Over Manual Implementation
**~500 lines** of complex batching logic → **~20 lines** of config
**Automatic** reference counting and cleanup
**Infinite loop prevention** built-in
**Type-safe** with TypeScript generics
**Reusable** across any domain (stock, pricing, availability, etc.)
**Tested** and battle-proven pattern
## Migration Guide
If you have an existing custom resource, extract these parts:
1. **Params type** (TParams): What do users pass to identify each item? (e.g., `number` for item IDs)
2. **Resource params type** (TResourceParams): What does your batch API accept? (e.g., `{ itemIds: number[] }`)
3. **Result type** (TResourceValue): What does your API return per item? (e.g., `StockInfo`)
4. **Service injection**: Your API service (e.g., `inject(MyService)`)
5. **Fetch method**: Your service method that calls the API
6. **Build params logic**: How to convert params list → resource params
7. **Get params from result logic**: How to extract the params from each result
8. **Cache key logic**: How to generate a unique string key from params
9. **Extract params logic**: How to get params list back from resource params (for status tracking)
Then implement these as abstract methods in your `BatchingResource` subclass!

View File

@@ -2,4 +2,5 @@ export * from './lib/errors';
export * from './lib/helpers';
export * from './lib/models';
export * from './lib/operators';
export * from './lib/resources';
export * from './lib/schemas';

View File

@@ -2,3 +2,4 @@ export * from './errors';
export * from './helpers';
export * from './models';
export * from './operators';
export * from './resources';

View File

@@ -0,0 +1,504 @@
import {
computed,
DestroyRef,
effect,
inject,
isSignal,
resource,
ResourceStatus,
Signal,
signal,
} from '@angular/core';
/**
* ResourceRef-like return type for batched resources.
* Provides item-specific loading, error, and status signals.
*/
export type BatchingResourceRef<TResourceValue> = {
/** Signal containing the resource value or undefined if not loaded */
value: Signal<TResourceValue | undefined>;
/** Signal indicating if the resource has been loaded */
hasValue: Signal<boolean>;
/** Signal indicating if the resource is currently loading */
isLoading: Signal<boolean>;
/** Signal containing any error that occurred */
error: Signal<unknown>;
/** Signal indicating the current status */
status: Signal<ResourceStatus>;
/** Reload this specific item */
reload: () => void;
};
/**
* Generic batching resource for optimizing multiple API requests.
* Collects params from multiple components, waits for a batching window,
* then makes a single API request with all params to minimize network calls.
*
* Features:
* - Automatic request batching with configurable window
* - Smart caching (including empty results to prevent infinite loops)
* - Reference counting for automatic cleanup
* - Per-item loading/error status tracking
* - Support for custom cache keys
*
* @example
* // Create a stock info resource
* export class StockInfoResource extends BatchingResource<number, FetchStockParams, StockInfo> {
* #stockService = inject(RemissionStockService);
*
* constructor() {
* super(250); // batchWindowMs
* }
*
* protected fetchFn(params: FetchStockParams, signal: AbortSignal) {
* return this.#stockService.fetchStockInfos(params, signal);
* }
*
* protected buildParams(itemIds: number[]) {
* return { itemIds };
* }
*
* protected getKeyFromResult(stock: StockInfo) {
* return stock.itemId;
* }
*
* protected getCacheKey(itemId: number) {
* return String(itemId);
* }
*
* protected extractKeysFromParams(params: FetchStockParams): number[] {
* return params.itemIds;
* }
* }
*
* // Use in a component
* const stockResource = inject(StockInfoResource);
* const stockInfo = stockResource.resource(itemId);
* const inStock = computed(() => stockInfo.value()?.inStock ?? 0);
*/
export abstract class BatchingResource<TParams, TResourceParams, TResourceValue> {
readonly #batchWindowMs: number;
/**
* Pending params waiting in the batch queue.
*/
#pendingKeys = signal<TParams[]>([]);
/**
* Committed params that trigger the actual API request.
*/
#committedKeys = signal<TParams[]>([]);
/**
* Reference counting for active components per key.
*/
#keyRefCounts = new Map<string, number>();
/**
* Cache for fetched results keyed by cache key.
*/
#cachedResults = signal<Map<string, TResourceValue>>(new Map());
/**
* Timeout ID for the batch window.
*/
#batchTimeoutId: ReturnType<typeof setTimeout> | null = null;
/**
* Per-item status tracking for loading, error, and hasValue states.
*/
#itemStatuses = signal<
Map<
string,
{
isLoading: boolean;
error: unknown;
hasValue: boolean;
}
>
>(new Map());
constructor(batchWindowMs = 100) {
this.#batchWindowMs = batchWindowMs;
}
/**
* Fetch data for multiple params at once.
* Implement this method to call your API service.
*/
protected abstract fetchFn(
params: TResourceParams,
abortSignal: AbortSignal,
): Promise<TResourceValue[]>;
/**
* Build request parameters from a list of params.
* Implement this method to convert params array to API request format.
*/
protected abstract buildParams(params: TParams[]): TResourceParams;
/**
* Extract the params from a result item.
* Implement this method to match results back to requested params.
*/
protected abstract getKeyFromResult(result: TResourceValue): TParams | undefined;
/**
* Generate a cache key from params.
* Implement this method for caching and deduplication.
*/
protected abstract getCacheKey(params: TParams): string;
/**
* Updates the status for a specific key.
*/
#updateItemStatus(
cacheKey: string,
status: Partial<{
isLoading: boolean;
error: unknown;
hasValue: boolean;
}>,
) {
this.#itemStatuses.update((map) => {
const newMap = new Map(map);
const currentStatus = newMap.get(cacheKey) || {
isLoading: false,
error: undefined,
hasValue: false,
};
newMap.set(cacheKey, { ...currentStatus, ...status });
return newMap;
});
}
/**
* Computed params for the resource loader.
* Only includes uncached params to minimize API payload.
*/
#params = computed<TResourceParams | undefined>(() => {
const cacheKeys = Array.from(
new Set(this.#committedKeys().map((params) => this.getCacheKey(params))),
);
const cached = this.#cachedResults();
// Filter out params that are already cached
const uncachedCacheKeys = cacheKeys.filter((cacheKey) => !cached.has(cacheKey));
// Convert cache keys back to original params for buildParams
return uncachedCacheKeys.length > 0
? this.buildParams(
this.#committedKeys().filter((params) =>
uncachedCacheKeys.includes(this.getCacheKey(params)),
),
)
: undefined;
});
/**
* Angular resource that fetches data.
*/
#resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }) => {
if (!params) return [];
const paramsList = this.#extractKeysFromParams(params);
// Mark all requested items as loading
paramsList.forEach((itemParams) => {
const cacheKey = this.getCacheKey(itemParams);
this.#updateItemStatus(cacheKey, {
isLoading: true,
error: undefined,
});
});
try {
const results = await this.fetchFn(params, abortSignal);
// Cache the fetched results
this.#cachedResults.update((map) => {
const newMap = new Map(map);
// First, cache all items that were returned with data
results.forEach((result) => {
const itemParams = this.getKeyFromResult(result);
if (itemParams !== undefined) {
const cacheKey = this.getCacheKey(itemParams);
newMap.set(cacheKey, result);
// Mark item as successfully loaded
this.#updateItemStatus(cacheKey, {
isLoading: false,
hasValue: true,
error: undefined,
});
}
});
// CRITICAL: Cache items that weren't returned (empty results) to prevent infinite loops
paramsList.forEach((itemParams) => {
const hasResult = results.some((r) => {
const resultKey = this.getKeyFromResult(r);
return resultKey !== undefined && this.getCacheKey(resultKey) === this.getCacheKey(itemParams);
});
if (!hasResult) {
const cacheKey = this.getCacheKey(itemParams);
// Cache with undefined value to indicate "no data available"
newMap.set(cacheKey, undefined as unknown as TResourceValue);
// Mark item as loaded (but with no value)
this.#updateItemStatus(cacheKey, {
isLoading: false,
hasValue: false,
error: undefined,
});
}
});
return newMap;
});
return results;
} catch (error) {
// Check if the error is an abort error (request was cancelled)
const isAbortError =
error instanceof DOMException && error.name === 'AbortError';
// Mark all items as no longer loading
paramsList.forEach((itemParams) => {
const cacheKey = this.getCacheKey(itemParams);
this.#updateItemStatus(cacheKey, {
isLoading: false,
// Only set error if it's not an abort (abort is normal during fast scrolling)
error: isAbortError ? undefined : error,
});
});
// Don't re-throw abort errors to prevent infinite loops
if (isAbortError) {
return [];
}
throw error;
}
},
});
/**
* Extract params from resource params for status tracking.
* Subclasses must implement this to extract the original params from resource params.
*/
protected abstract extractKeysFromParams(params: TResourceParams): TParams[];
/**
* Wrapper to call the abstract method
*/
#extractKeysFromParams(params: TResourceParams): TParams[] {
return this.extractKeysFromParams(params);
}
/**
* Adds params to the pending batch queue and increments reference count.
*/
#addKeys(params: TParams[]) {
// Increment reference count for each params
params.forEach((itemParams) => {
const cacheKey = this.getCacheKey(itemParams);
const currentCount = this.#keyRefCounts.get(cacheKey) || 0;
this.#keyRefCounts.set(cacheKey, currentCount + 1);
});
// Add to pending batch
this.#pendingKeys.update((current) => [...current, ...params]);
// Clear existing timeout if any
if (this.#batchTimeoutId !== null) {
clearTimeout(this.#batchTimeoutId);
}
// Schedule batch commit after batch window
this.#batchTimeoutId = setTimeout(() => {
const pending = this.#pendingKeys();
this.#committedKeys.update((current) =>
Array.from(new Set([...current, ...pending])),
);
this.#pendingKeys.set([]);
this.#batchTimeoutId = null;
}, this.#batchWindowMs);
}
/**
* Removes params from pending and committed tracking.
*/
#removeKeys(params: TParams[]) {
// Decrement reference count and collect cache keys with zero refs
const cacheKeysToRemove: string[] = [];
params.forEach((itemParams) => {
const cacheKey = this.getCacheKey(itemParams);
const currentCount = this.#keyRefCounts.get(cacheKey) || 0;
const newCount = Math.max(0, currentCount - 1);
if (newCount === 0) {
this.#keyRefCounts.delete(cacheKey);
cacheKeysToRemove.push(cacheKey);
} else {
this.#keyRefCounts.set(cacheKey, newCount);
}
});
// Only remove params that have zero references
if (cacheKeysToRemove.length === 0) {
return;
}
// Remove from pending batch
this.#pendingKeys.update((current) =>
current.filter((p) => !cacheKeysToRemove.includes(this.getCacheKey(p))),
);
// Remove from committed keys
this.#committedKeys.update((current) =>
current.filter((p) => !cacheKeysToRemove.includes(this.getCacheKey(p))),
);
// Clear status for removed items to prevent memory accumulation
this.#itemStatuses.update((map) => {
const newMap = new Map(map);
cacheKeysToRemove.forEach((cacheKey) => {
newMap.delete(cacheKey);
});
return newMap;
});
}
/**
* Reloads all committed items by clearing the cache.
*/
reload() {
this.#cachedResults.set(new Map());
this.#itemStatuses.set(new Map());
return this.#resource.reload();
}
/**
* Reloads specific items by removing them from cache.
*/
reloadKeys(params: TParams[]) {
const cacheKeysToRemove = params.map((p) => this.getCacheKey(p));
// Remove from cache
this.#cachedResults.update((map) => {
const newMap = new Map(map);
cacheKeysToRemove.forEach((cacheKey) => {
newMap.delete(cacheKey);
});
return newMap;
});
// Clear status
this.#itemStatuses.update((map) => {
const newMap = new Map(map);
cacheKeysToRemove.forEach((cacheKey) => {
newMap.delete(cacheKey);
});
return newMap;
});
return this.#resource.reload();
}
/**
* Creates a ResourceRef-like object for specific params.
* Automatically registers the params for batched fetching and cleans up on component destruction.
*/
resource(
params: Signal<TParams> | TParams,
): BatchingResourceRef<TResourceValue> {
const destroyRef = inject(DestroyRef);
const paramsSignal = isSignal(params) ? params : signal(params);
// Track the last registered params
let lastRegisteredParams: TParams | null = null;
// Use effect to handle params registration
effect(() => {
const currentParams = paramsSignal();
// Register new params if changed
if (currentParams !== lastRegisteredParams) {
// Remove old params if exists
if (lastRegisteredParams !== null) {
this.#removeKeys([lastRegisteredParams]);
}
// Add new params
this.#addKeys([currentParams]);
lastRegisteredParams = currentParams;
}
});
// Cleanup when component is destroyed
destroyRef.onDestroy(() => {
if (lastRegisteredParams !== null) {
this.#removeKeys([lastRegisteredParams]);
}
});
// Helper to get current cache key
const getCacheKey = () => this.getCacheKey(paramsSignal());
// Create item-specific computed signals
const value = computed(() => {
const cacheKey = getCacheKey();
return this.#cachedResults().get(cacheKey);
});
const hasValue = computed(() => {
return this.#cachedResults().has(getCacheKey());
});
const isLoading = computed(() => {
const cacheKey = getCacheKey();
const statusMap = this.#itemStatuses();
const itemStatus = statusMap.get(cacheKey);
// Return loading status from item-specific status map
return itemStatus?.isLoading ?? false;
});
const error = computed(() => {
const statusMap = this.#itemStatuses();
return statusMap.get(getCacheKey())?.error;
});
const status = computed<ResourceStatus>(() => {
if (error()) return 'error' as ResourceStatus;
if (isLoading()) return 'loading' as ResourceStatus;
if (hasValue()) return 'resolved' as ResourceStatus;
return 'idle' as ResourceStatus;
});
const reload = () => {
this.reloadKeys([paramsSignal()]);
};
return {
value,
hasValue,
isLoading,
error,
status,
reload,
};
}
}

View File

@@ -0,0 +1,4 @@
export {
BatchingResource,
type BatchingResourceRef,
} from './batching-resource.base';

View File

@@ -1,20 +1,101 @@
import { Injectable, inject, resource, signal } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { BatchingResource } from '@isa/common/data-access';
import { RemissionStockService } from '../services';
import { FetchStockInStock } from '../schemas';
import { StockInfo } from '../models';
/**
* Smart batching resource for stock information.
* Collects item params from multiple components, waits for a batching window,
* then makes a single API request with all items to minimize network calls.
*
* @example
* // In a component
* const stockInfoResource = inject(StockInfoResource);
* const stockInfo = stockInfoResource.resource({ itemId: 123, stockId: 456 });
*
* // Access the stock info
* const inStock = computed(() => stockInfo.value()?.inStock ?? 0);
*/
@Injectable({ providedIn: 'root' })
export class StockResource {
export class StockInfoResource extends BatchingResource<
{ itemId: number; stockId?: number },
FetchStockInStock,
StockInfo
> {
#stockService = inject(RemissionStockService);
#params = signal<FetchStockInStock>({ itemIds: [], stockId: undefined });
params(params: Partial<FetchStockInStock>) {
this.#params.update((current) => ({ ...current, ...params }));
constructor() {
super(250); // batchWindowMs
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }) =>
this.#stockService.fetchStockInfos(params, abortSignal),
});
/**
* Fetch stock information for multiple items.
*/
protected fetchFn(
params: FetchStockInStock,
signal: AbortSignal,
): Promise<StockInfo[]> {
return this.#stockService.fetchStockInfos(params, signal);
}
/**
* Build API request params from list of item params.
*/
protected buildParams(
paramsList: { itemId: number; stockId?: number }[],
): FetchStockInStock {
return {
itemIds: paramsList.map((p) => p.itemId),
stockId: paramsList[0]?.stockId,
};
}
/**
* Extract params from result for cache matching.
*/
protected getKeyFromResult(
stock: StockInfo,
): { itemId: number; stockId?: number } | undefined {
return stock.itemId !== undefined ? { itemId: stock.itemId } : undefined;
}
/**
* Generate cache key from params.
*/
protected getCacheKey(params: { itemId: number; stockId?: number }): string {
return `${params.stockId ?? 'default'}-${params.itemId}`;
}
/**
* Extract params from resource params for status tracking.
* Required by BatchingResource base class.
*/
protected extractKeysFromParams(
params: FetchStockInStock,
): { itemId: number; stockId?: number }[] {
return params.itemIds.map((itemId) => ({
itemId,
stockId: params.stockId,
}));
}
/**
* Reloads specific items by removing them from cache.
* Convenience method that accepts item IDs instead of full params.
*
* @param itemIds - Array of item IDs to reload
* @returns Promise that resolves when reload completes
*
* @example
* stockInfoResource.reloadItems([123, 456, 789]);
*/
reloadItems(itemIds: number[]) {
// Convert itemIds to params (stockId will be undefined, matching all stockIds)
const paramsToReload = itemIds.flatMap((itemId) => [
{ itemId, stockId: undefined },
{ itemId }, // stockId undefined variant
]);
return this.reloadKeys(paramsToReload);
}
}

View File

@@ -76,7 +76,7 @@ export class RemissionListItemComponent {
* Signal providing the current breakpoint state.
* Used to determine layout orientation and visibility of action buttons.
*/
desktopBreakpoint = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]);
desktopBreakpoint = breakpoint([Breakpoint.DesktopL, Breakpoint.DesktopXL]);
/**
* Signal providing the current remission list type (Abteilung or Pflicht).

View File

@@ -1,56 +1,56 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
computed,
} from '@angular/core';
import { Item } from '@isa/catalogue/data-access';
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',
templateUrl: './search-item-to-remit.component.html',
styleUrls: ['./search-item-to-remit.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ProductInfoComponent, TextButtonComponent],
})
export class SearchItemToRemitComponent {
host = inject(SearchItemToRemitDialogComponent);
quantityAndReasonDialog = injectDialog(
SelectRemiQuantityAndReasonDialogComponent,
);
item = input.required<Item>();
inStock = input.required<number>();
desktopBreakpoint = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]);
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);
}
}
}
}
import {
ChangeDetectionStrategy,
Component,
inject,
input,
computed,
} from '@angular/core';
import { Item } from '@isa/catalogue/data-access';
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',
templateUrl: './search-item-to-remit.component.html',
styleUrls: ['./search-item-to-remit.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ProductInfoComponent, TextButtonComponent],
})
export class SearchItemToRemitComponent {
host = inject(SearchItemToRemitDialogComponent);
quantityAndReasonDialog = injectDialog(
SelectRemiQuantityAndReasonDialogComponent,
);
item = input.required<Item>();
inStock = input.required<number>();
desktopBreakpoint = breakpoint([Breakpoint.DesktopL, Breakpoint.DesktopXL]);
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,87 +1,87 @@
import { inject, isSignal, Signal } from '@angular/core';
import { BreakpointObserver } from '@angular/cdk/layout';
import { exhaustMap, isObservable, map, Observable, of, startWith } from 'rxjs';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { coerceArray } from '@angular/cdk/coercion';
/**
* Enum-like object defining various breakpoints for responsive design.
*/
export const Breakpoint = {
Tablet: 'tablet',
Desktop: 'desktop',
DekstopL: 'dekstop-l',
DekstopXL: 'dekstop-xl',
} as const;
/**
* Type representing the possible values of the Breakpoint object.
*/
export type Breakpoint = (typeof Breakpoint)[keyof typeof Breakpoint];
/**
* Mapping of Breakpoint values to their corresponding CSS media query selectors.
*/
const BreakpointSelector = {
[Breakpoint.Tablet]: '(max-width: 1279px)',
[Breakpoint.Desktop]: '(min-width: 1280px) and (max-width: 1439px)',
[Breakpoint.DekstopL]: '(min-width: 1440px) and (max-width: 1919px)',
[Breakpoint.DekstopXL]: '(min-width: 1920px)',
};
/**
* Observes viewport breakpoints and returns an Observable that emits whether the specified breakpoints match.
*
* This function accepts a Breakpoint, an array of Breakpoints, a Signal, or an Observable thereof and returns
* an Observable that emits a boolean indicating if the given breakpoints match the current viewport.
*
* @param breakpoints - A Breakpoint, array of Breakpoints, Signal, or Observable representing breakpoints.
* @returns An Observable that emits a boolean indicating if the specified breakpoints match.
*/
export function breakpoint$(
breakpoints:
| (Breakpoint | Breakpoint[])
| Signal<Breakpoint | Breakpoint[]>
| Observable<Breakpoint | Breakpoint[]>,
): Observable<boolean> {
const bpObserver = inject(BreakpointObserver);
const breakpoints$ = isObservable(breakpoints)
? breakpoints
: isSignal(breakpoints)
? toObservable(breakpoints)
: of(breakpoints);
const breakpointSelectors$ = breakpoints$.pipe(
map((bp) => coerceArray(bp).map((b) => BreakpointSelector[b])),
);
const match$ = breakpointSelectors$.pipe(
exhaustMap((selectors) =>
bpObserver.observe(selectors).pipe(
map((result) => result.matches),
startWith(bpObserver.isMatched(selectors)),
),
),
);
return match$;
}
/**
* Converts the breakpoint$ Observable into a Signal to provide reactive breakpoint matching.
*
* This function wraps the breakpoint$ function, converting its Observable result into a Signal,
* which can be used with Angular's reactive system. The Signal returns a boolean indicating whether
* the specified breakpoints currently match or undefined if not yet determined.
*
* @param breakpoints - A Breakpoint, array of Breakpoints, Signal, or Observable representing breakpoints.
* @returns A Signal<boolean | undefined> indicating the match state of the breakpoints.
*/
export function breakpoint(
breakpoints:
| (Breakpoint | Breakpoint[])
| Signal<Breakpoint | Breakpoint[]>
| Observable<Breakpoint | Breakpoint[]>,
): Signal<boolean | undefined> {
return toSignal(breakpoint$(breakpoints));
}
import { inject, isSignal, Signal } from '@angular/core';
import { BreakpointObserver } from '@angular/cdk/layout';
import { exhaustMap, isObservable, map, Observable, of, startWith } from 'rxjs';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { coerceArray } from '@angular/cdk/coercion';
/**
* Enum-like object defining various breakpoints for responsive design.
*/
export const Breakpoint = {
Tablet: 'tablet',
Desktop: 'desktop',
DesktopL: 'desktop-l',
DesktopXL: 'desktop-xl',
} as const;
/**
* Type representing the possible values of the Breakpoint object.
*/
export type Breakpoint = (typeof Breakpoint)[keyof typeof Breakpoint];
/**
* Mapping of Breakpoint values to their corresponding CSS media query selectors.
*/
const BreakpointSelector = {
[Breakpoint.Tablet]: '(max-width: 1279px)',
[Breakpoint.Desktop]: '(min-width: 1280px) and (max-width: 1439px)',
[Breakpoint.DesktopL]: '(min-width: 1440px) and (max-width: 1919px)',
[Breakpoint.DesktopXL]: '(min-width: 1920px)',
};
/**
* Observes viewport breakpoints and returns an Observable that emits whether the specified breakpoints match.
*
* This function accepts a Breakpoint, an array of Breakpoints, a Signal, or an Observable thereof and returns
* an Observable that emits a boolean indicating if the given breakpoints match the current viewport.
*
* @param breakpoints - A Breakpoint, array of Breakpoints, Signal, or Observable representing breakpoints.
* @returns An Observable that emits a boolean indicating if the specified breakpoints match.
*/
export function breakpoint$(
breakpoints:
| (Breakpoint | Breakpoint[])
| Signal<Breakpoint | Breakpoint[]>
| Observable<Breakpoint | Breakpoint[]>,
): Observable<boolean> {
const bpObserver = inject(BreakpointObserver);
const breakpoints$ = isObservable(breakpoints)
? breakpoints
: isSignal(breakpoints)
? toObservable(breakpoints)
: of(breakpoints);
const breakpointSelectors$ = breakpoints$.pipe(
map((bp) => coerceArray(bp).map((b) => BreakpointSelector[b])),
);
const match$ = breakpointSelectors$.pipe(
exhaustMap((selectors) =>
bpObserver.observe(selectors).pipe(
map((result) => result.matches),
startWith(bpObserver.isMatched(selectors)),
),
),
);
return match$;
}
/**
* Converts the breakpoint$ Observable into a Signal to provide reactive breakpoint matching.
*
* This function wraps the breakpoint$ function, converting its Observable result into a Signal,
* which can be used with Angular's reactive system. The Signal returns a boolean indicating whether
* the specified breakpoints currently match or undefined if not yet determined.
*
* @param breakpoints - A Breakpoint, array of Breakpoints, Signal, or Observable representing breakpoints.
* @returns A Signal<boolean | undefined> indicating the match state of the breakpoints.
*/
export function breakpoint(
breakpoints:
| (Breakpoint | Breakpoint[])
| Signal<Breakpoint | Breakpoint[]>
| Observable<Breakpoint | Breakpoint[]>,
): Signal<boolean | undefined> {
return toSignal(breakpoint$(breakpoints));
}