mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
committed by
Nino Righi
parent
5f74c6ddf8
commit
40c9d51dfc
16
.github/instructions/nx.instructions.md
vendored
16
.github/instructions/nx.instructions.md
vendored
@@ -9,14 +9,12 @@ You are in an nx workspace using Nx 21.2.1 and npm as the package manager.
|
||||
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
|
||||
|
||||
# General Guidelines
|
||||
|
||||
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
|
||||
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
|
||||
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
|
||||
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
|
||||
|
||||
# Generation Guidelines
|
||||
|
||||
If the user wants to generate something, use the following flow:
|
||||
|
||||
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
|
||||
@@ -31,11 +29,19 @@ If the user wants to generate something, use the following flow:
|
||||
- use the information provided in the log file to answer the user's question or continue with what they were doing
|
||||
|
||||
# Running Tasks Guidelines
|
||||
|
||||
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
|
||||
|
||||
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
|
||||
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
|
||||
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
|
||||
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
|
||||
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.
|
||||
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.
|
||||
|
||||
|
||||
# CI Error Guidelines
|
||||
If the user wants help with fixing an error in their CI pipeline, use the following flow:
|
||||
- Retrieve the list of current CI Pipeline Executions (CIPEs) using the 'nx_cloud_cipe_details' tool
|
||||
- If there are any errors, use the 'nx_cloud_fix_cipe_failure' tool to retrieve the logs for a specific task
|
||||
- Use the task logs to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
|
||||
- Make sure that the problem is fixed by running the task that you passed into the 'nx_cloud_fix_cipe_failure' tool
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ describe('InFlight Decorators', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('InFlight', () => {
|
||||
@@ -75,17 +76,27 @@ describe('InFlight Decorators', () => {
|
||||
const promise1 = service.fetchWithError();
|
||||
const promise2 = service.fetchWithError();
|
||||
|
||||
// Handle the promises immediately to avoid unhandled rejections
|
||||
const resultsPromise = Promise.allSettled([promise1, promise2]);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Both should reject with the same error
|
||||
await expect(promise1).rejects.toThrow('Test error');
|
||||
await expect(promise2).rejects.toThrow('Test error');
|
||||
const results = await resultsPromise;
|
||||
expect(results[0].status).toBe('rejected');
|
||||
expect(results[1].status).toBe('rejected');
|
||||
expect((results[0] as PromiseRejectedResult).reason.message).toBe('Test error');
|
||||
expect((results[1] as PromiseRejectedResult).reason.message).toBe('Test error');
|
||||
expect(service.callCount).toBe(1);
|
||||
|
||||
// Should allow new call after error
|
||||
const promise3 = service.fetchWithError();
|
||||
const promise3Result = Promise.allSettled([promise3]);
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise3).rejects.toThrow('Test error');
|
||||
|
||||
const [result3] = await promise3Result;
|
||||
expect(result3.status).toBe('rejected');
|
||||
expect((result3 as PromiseRejectedResult).reason.message).toBe('Test error');
|
||||
expect(service.callCount).toBe(2);
|
||||
});
|
||||
|
||||
@@ -307,14 +318,20 @@ describe('InFlight Decorators', () => {
|
||||
|
||||
// First call that errors
|
||||
const promise1 = service.fetchWithError();
|
||||
const promise1Result = Promise.allSettled([promise1]);
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise1).rejects.toThrow('API Error');
|
||||
const result1 = await promise1Result;
|
||||
expect(result1[0].status).toBe('rejected');
|
||||
expect((result1[0] as PromiseRejectedResult).reason.message).toBe('API Error');
|
||||
expect(service.callCount).toBe(1);
|
||||
|
||||
// Second call should not use cache (errors aren't cached)
|
||||
const promise2 = service.fetchWithError();
|
||||
const promise2Result = Promise.allSettled([promise2]);
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise2).rejects.toThrow('API Error');
|
||||
const result2 = await promise2Result;
|
||||
expect(result2[0].status).toBe('rejected');
|
||||
expect((result2[0] as PromiseRejectedResult).reason.message).toBe('API Error');
|
||||
expect(service.callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,8 +20,8 @@ export function InFlight<
|
||||
const inFlightMap = new WeakMap<object, Promise<any>>();
|
||||
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
_target: any,
|
||||
_propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor,
|
||||
): PropertyDescriptor {
|
||||
const originalMethod = descriptor.value;
|
||||
@@ -39,15 +39,9 @@ export function InFlight<
|
||||
// Create new request and store it
|
||||
const promise = originalMethod
|
||||
.apply(this, args)
|
||||
.then((result: any) => {
|
||||
// Clean up after successful completion
|
||||
.finally(() => {
|
||||
// Always clean up in-flight request
|
||||
inFlightMap.delete(this);
|
||||
return result;
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// Clean up after error
|
||||
inFlightMap.delete(this);
|
||||
throw error;
|
||||
});
|
||||
|
||||
inFlightMap.set(this, promise);
|
||||
@@ -92,8 +86,8 @@ export function InFlightWithKey<T extends (...args: any[]) => Promise<any>>(
|
||||
const inFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
|
||||
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
_target: any,
|
||||
_propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor,
|
||||
): PropertyDescriptor {
|
||||
const originalMethod = descriptor.value;
|
||||
@@ -106,7 +100,7 @@ export function InFlightWithKey<T extends (...args: any[]) => Promise<any>>(
|
||||
if (!inFlightMap.has(this)) {
|
||||
inFlightMap.set(this, new Map());
|
||||
}
|
||||
const instanceMap = inFlightMap.get(this)!;
|
||||
const instanceMap = inFlightMap.get(this) as Map<string, Promise<any>>;
|
||||
|
||||
// Generate cache key
|
||||
const key = options.keyGenerator
|
||||
@@ -122,15 +116,9 @@ export function InFlightWithKey<T extends (...args: any[]) => Promise<any>>(
|
||||
// Create new request and store it
|
||||
const promise = originalMethod
|
||||
.apply(this, args)
|
||||
.then((result: any) => {
|
||||
// Clean up after successful completion
|
||||
.finally(() => {
|
||||
// Always clean up in-flight request
|
||||
instanceMap.delete(key);
|
||||
return result;
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// Clean up after error
|
||||
instanceMap.delete(key);
|
||||
throw error;
|
||||
});
|
||||
|
||||
instanceMap.set(key, promise);
|
||||
@@ -183,8 +171,8 @@ export function InFlightWithCache<T extends (...args: any[]) => Promise<any>>(
|
||||
>();
|
||||
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
_target: any,
|
||||
_propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor,
|
||||
): PropertyDescriptor {
|
||||
const originalMethod = descriptor.value;
|
||||
@@ -198,8 +186,8 @@ export function InFlightWithCache<T extends (...args: any[]) => Promise<any>>(
|
||||
inFlightMap.set(this, new Map());
|
||||
cacheMap.set(this, new Map());
|
||||
}
|
||||
const instanceInFlight = inFlightMap.get(this)!;
|
||||
const instanceCache = cacheMap.get(this)!;
|
||||
const instanceInFlight = inFlightMap.get(this) as Map<string, Promise<any>>;
|
||||
const instanceCache = cacheMap.get(this) as Map<string, { result: any; expiry: number }>;
|
||||
|
||||
// Generate cache key
|
||||
const key = options.keyGenerator
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// Using Jest (default for existing libraries)
|
||||
import { calculateAvailableStock } from './calc-available-stock.helper';
|
||||
|
||||
describe('calculateAvailableStock', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// Using Jest (default for existing libraries)
|
||||
import { calculateStockToRemit } from './calc-stock-to-remit.helper';
|
||||
|
||||
describe('calculateStockToRemit', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// Using Jest (default for existing libraries)
|
||||
import { calculateTargetStock } from './calc-target-stock.helper';
|
||||
|
||||
describe('calculateTargetStock', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# remission-feature-remission-list
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test remission-feature-remission-list` to execute the unit tests.
|
||||
# remi-remission-list
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test remi-remission-list` to execute the unit tests.
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
export default {
|
||||
displayName: 'remission-feature-remission-list',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory:
|
||||
'../../../../coverage/libs/remission/feature/remission-list',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
export default {
|
||||
displayName: 'remi-remission-list',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory:
|
||||
'../../../../coverage/libs/remission/feature/remission-list',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"name": "remission-feature-remission-list",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/remission/feature/remission-list/src",
|
||||
"prefix": "remi",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/remission/feature/remission-list/jest.config.ts"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "remi-remission-list",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/remission/feature/remission-list/src",
|
||||
"prefix": "remi",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/remission/feature/remission-list/jest.config.ts"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,214 +1,214 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { Validators } from '@angular/forms';
|
||||
import {
|
||||
calculateAvailableStock,
|
||||
calculateStockToRemit,
|
||||
calculateTargetStock,
|
||||
RemissionListType,
|
||||
RemissionSelectionStore,
|
||||
ReturnItem,
|
||||
ReturnSuggestion,
|
||||
StockInfo,
|
||||
} from '@isa/remission/data-access';
|
||||
import {
|
||||
ProductInfoComponent,
|
||||
ProductShelfMetaInfoComponent,
|
||||
ProductStockInfoComponent,
|
||||
} from '@isa/remission/shared/product';
|
||||
import { TextButtonComponent } from '@isa/ui/buttons';
|
||||
import { injectTextInputDialog } from '@isa/ui/dialog';
|
||||
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
||||
import { injectRemissionListType } from '../injects/inject-remission-list-type';
|
||||
|
||||
/**
|
||||
* Component representing a single item in the remission list.
|
||||
*
|
||||
* Displays product information, stock details, and allows the user to change
|
||||
* the remission quantity via a dialog. Handles both `ReturnItem` and
|
||||
* `ReturnSuggestion` types, adapting logic based on the current remission list type.
|
||||
*
|
||||
* @remarks
|
||||
* - Uses OnPush change detection for performance.
|
||||
* - Relies on signals for local state and computed values.
|
||||
* - Follows workspace guidelines for type safety, clean code, and documentation.
|
||||
*
|
||||
* @see https://context7.com/angular/angular/20.0.0/llms.txt?topic=component
|
||||
*/
|
||||
@Component({
|
||||
selector: 'remi-feature-remission-list-item',
|
||||
templateUrl: './remission-list-item.component.html',
|
||||
styleUrl: './remission-list-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ProductInfoComponent,
|
||||
ProductStockInfoComponent,
|
||||
ProductShelfMetaInfoComponent,
|
||||
TextButtonComponent,
|
||||
ClientRowImports,
|
||||
ItemRowDataImports,
|
||||
],
|
||||
})
|
||||
export class RemissionListItemComponent {
|
||||
/**
|
||||
* Dialog service for prompting the user to enter a remission quantity.
|
||||
* @private
|
||||
*/
|
||||
#dialog = injectTextInputDialog();
|
||||
|
||||
/**
|
||||
* Store for managing selected remission quantities.
|
||||
* @private
|
||||
*/
|
||||
#store = inject(RemissionSelectionStore);
|
||||
|
||||
/**
|
||||
* Signal indicating if the current layout is mobile (tablet breakpoint or below).
|
||||
*/
|
||||
mobileBreakpoint = breakpoint([Breakpoint.Tablet]);
|
||||
|
||||
/**
|
||||
* Signal providing the current remission list type (Abteilung or Pflicht).
|
||||
*/
|
||||
remissionListType = injectRemissionListType();
|
||||
|
||||
/**
|
||||
* The item to display in the list.
|
||||
* Can be either a ReturnItem or a ReturnSuggestion.
|
||||
*/
|
||||
item = input.required<ReturnItem | ReturnSuggestion>();
|
||||
|
||||
/**
|
||||
* Stock information for the item.
|
||||
*/
|
||||
stock = input.required<StockInfo>();
|
||||
|
||||
/**
|
||||
* Optional product group value for display or filtering.
|
||||
*/
|
||||
productGroupValue = input<string>('');
|
||||
|
||||
/**
|
||||
* Computes the orientation for the product info section based on breakpoint.
|
||||
* @returns 'horizontal' if mobile, otherwise 'vertical'
|
||||
*/
|
||||
remiProductInfoOrientation = computed(() => {
|
||||
return this.mobileBreakpoint() ? 'horizontal' : 'vertical';
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the remaining quantity in stock for the current item.
|
||||
*/
|
||||
remainingQuantityInStock = computed(
|
||||
() => this.item()?.remainingQuantityInStock,
|
||||
);
|
||||
|
||||
/**
|
||||
* Computes the predefined return quantity for the current item.
|
||||
* - For Abteilung (suggestion), uses the nested returnItem's predefined quantity.
|
||||
* - For Pflicht (item), uses the item's predefined quantity.
|
||||
* - Returns 0 if not available.
|
||||
*/
|
||||
predefinedReturnQuantity = computed(() => {
|
||||
const item = this.item();
|
||||
|
||||
// ReturnSuggestion
|
||||
if (this.remissionListType() === RemissionListType.Abteilung) {
|
||||
return (
|
||||
(item as ReturnSuggestion)?.returnItem?.data
|
||||
?.predefinedReturnQuantity ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
// ReturnItem
|
||||
if (this.remissionListType() === RemissionListType.Pflicht) {
|
||||
return (item as ReturnItem)?.predefinedReturnQuantity ?? 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the available stock for the item using stock and removedFromStock.
|
||||
* @returns The calculated available stock.
|
||||
*/
|
||||
availableStock = computed(() => {
|
||||
return calculateAvailableStock({
|
||||
stock: this.stock()?.inStock,
|
||||
removedFromStock: this.stock()?.removedFromStock,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the quantity to remit for the current item.
|
||||
* - Uses the selected quantity from the store if available.
|
||||
* - Otherwise, calculates based on available stock, predefined return quantity, and remaining quantity.
|
||||
* @returns The quantity to remit.
|
||||
*/
|
||||
stockToRemit = computed(() => {
|
||||
const remissionItemId = this.item()?.id;
|
||||
return (
|
||||
this.#store.selectedQuantity()?.[remissionItemId!] ??
|
||||
calculateStockToRemit({
|
||||
availableStock: this.availableStock(),
|
||||
predefinedReturnQuantity: this.predefinedReturnQuantity(),
|
||||
remainingQuantityInStock: this.remainingQuantityInStock(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the target stock after remission.
|
||||
* @returns The calculated target stock.
|
||||
*/
|
||||
targetStock = computed(() => {
|
||||
return calculateTargetStock({
|
||||
availableStock: this.availableStock(),
|
||||
stockToRemit: this.stockToRemit(),
|
||||
remainingQuantityInStock: this.remainingQuantityInStock(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens a dialog to allow the user to change the remission quantity for the item.
|
||||
* Validates the input and updates the remission quantity in the store if valid.
|
||||
*
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async openRemissionQuantityDialog(): Promise<void> {
|
||||
const dialogRef = this.#dialog({
|
||||
title: 'Remi-Menge ändern',
|
||||
data: {
|
||||
message: 'Wie viele Exemplare können remittiert werden?',
|
||||
inputLabel: 'Remi-Menge',
|
||||
inputValidation: [
|
||||
{
|
||||
errorKey: 'required',
|
||||
inputValidator: Validators.required,
|
||||
errorText: 'Bitte geben Sie eine Menge an.',
|
||||
},
|
||||
{
|
||||
errorKey: 'pattern',
|
||||
inputValidator: Validators.pattern(/^[1-9][0-9]*$/),
|
||||
errorText: 'Die Menge muss mindestens 1 sein.',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
const itemId = this.item()?.id;
|
||||
const quantity = Number(result?.inputValue);
|
||||
|
||||
if (itemId && quantity > 0) {
|
||||
this.#store.updateRemissionQuantity(itemId, this.item(), quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { Validators } from '@angular/forms';
|
||||
import {
|
||||
calculateAvailableStock,
|
||||
calculateStockToRemit,
|
||||
calculateTargetStock,
|
||||
RemissionListType,
|
||||
RemissionSelectionStore,
|
||||
ReturnItem,
|
||||
ReturnSuggestion,
|
||||
StockInfo,
|
||||
} from '@isa/remission/data-access';
|
||||
import {
|
||||
ProductInfoComponent,
|
||||
ProductShelfMetaInfoComponent,
|
||||
ProductStockInfoComponent,
|
||||
} from '@isa/remission/shared/product';
|
||||
import { TextButtonComponent } from '@isa/ui/buttons';
|
||||
import { injectTextInputDialog } from '@isa/ui/dialog';
|
||||
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
||||
import { injectRemissionListType } from '../injects/inject-remission-list-type';
|
||||
|
||||
/**
|
||||
* Component representing a single item in the remission list.
|
||||
*
|
||||
* Displays product information, stock details, and allows the user to change
|
||||
* the remission quantity via a dialog. Handles both `ReturnItem` and
|
||||
* `ReturnSuggestion` types, adapting logic based on the current remission list type.
|
||||
*
|
||||
* @remarks
|
||||
* - Uses OnPush change detection for performance.
|
||||
* - Relies on signals for local state and computed values.
|
||||
* - Follows workspace guidelines for type safety, clean code, and documentation.
|
||||
*
|
||||
* @see https://context7.com/angular/angular/20.0.0/llms.txt?topic=component
|
||||
*/
|
||||
@Component({
|
||||
selector: 'remi-feature-remission-list-item',
|
||||
templateUrl: './remission-list-item.component.html',
|
||||
styleUrl: './remission-list-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ProductInfoComponent,
|
||||
ProductStockInfoComponent,
|
||||
ProductShelfMetaInfoComponent,
|
||||
TextButtonComponent,
|
||||
ClientRowImports,
|
||||
ItemRowDataImports,
|
||||
],
|
||||
})
|
||||
export class RemissionListItemComponent {
|
||||
/**
|
||||
* Dialog service for prompting the user to enter a remission quantity.
|
||||
* @private
|
||||
*/
|
||||
#dialog = injectTextInputDialog();
|
||||
|
||||
/**
|
||||
* Store for managing selected remission quantities.
|
||||
* @private
|
||||
*/
|
||||
#store = inject(RemissionSelectionStore);
|
||||
|
||||
/**
|
||||
* Signal indicating if the current layout is mobile (tablet breakpoint or below).
|
||||
*/
|
||||
mobileBreakpoint = breakpoint([Breakpoint.Tablet]);
|
||||
|
||||
/**
|
||||
* Signal providing the current remission list type (Abteilung or Pflicht).
|
||||
*/
|
||||
remissionListType = injectRemissionListType();
|
||||
|
||||
/**
|
||||
* The item to display in the list.
|
||||
* Can be either a ReturnItem or a ReturnSuggestion.
|
||||
*/
|
||||
item = input.required<ReturnItem | ReturnSuggestion>();
|
||||
|
||||
/**
|
||||
* Stock information for the item.
|
||||
*/
|
||||
stock = input.required<StockInfo>();
|
||||
|
||||
/**
|
||||
* Optional product group value for display or filtering.
|
||||
*/
|
||||
productGroupValue = input<string>('');
|
||||
|
||||
/**
|
||||
* Computes the orientation for the product info section based on breakpoint.
|
||||
* @returns 'horizontal' if mobile, otherwise 'vertical'
|
||||
*/
|
||||
remiProductInfoOrientation = computed(() => {
|
||||
return this.mobileBreakpoint() ? 'horizontal' : 'vertical';
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the remaining quantity in stock for the current item.
|
||||
*/
|
||||
remainingQuantityInStock = computed(
|
||||
() => this.item()?.remainingQuantityInStock,
|
||||
);
|
||||
|
||||
/**
|
||||
* Computes the predefined return quantity for the current item.
|
||||
* - For Abteilung (suggestion), uses the nested returnItem's predefined quantity.
|
||||
* - For Pflicht (item), uses the item's predefined quantity.
|
||||
* - Returns 0 if not available.
|
||||
*/
|
||||
predefinedReturnQuantity = computed(() => {
|
||||
const item = this.item();
|
||||
|
||||
// ReturnSuggestion
|
||||
if (this.remissionListType() === RemissionListType.Abteilung) {
|
||||
return (
|
||||
(item as ReturnSuggestion)?.returnItem?.data
|
||||
?.predefinedReturnQuantity ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
// ReturnItem
|
||||
if (this.remissionListType() === RemissionListType.Pflicht) {
|
||||
return (item as ReturnItem)?.predefinedReturnQuantity ?? 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the available stock for the item using stock and removedFromStock.
|
||||
* @returns The calculated available stock.
|
||||
*/
|
||||
availableStock = computed(() => {
|
||||
return calculateAvailableStock({
|
||||
stock: this.stock()?.inStock,
|
||||
removedFromStock: this.stock()?.removedFromStock,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the quantity to remit for the current item.
|
||||
* - Uses the selected quantity from the store if available.
|
||||
* - Otherwise, calculates based on available stock, predefined return quantity, and remaining quantity.
|
||||
* @returns The quantity to remit.
|
||||
*/
|
||||
stockToRemit = computed(() => {
|
||||
const remissionItemId = this.item()?.id;
|
||||
return (
|
||||
this.#store.selectedQuantity()?.[remissionItemId!] ??
|
||||
calculateStockToRemit({
|
||||
availableStock: this.availableStock(),
|
||||
predefinedReturnQuantity: this.predefinedReturnQuantity(),
|
||||
remainingQuantityInStock: this.remainingQuantityInStock(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the target stock after remission.
|
||||
* @returns The calculated target stock.
|
||||
*/
|
||||
targetStock = computed(() => {
|
||||
return calculateTargetStock({
|
||||
availableStock: this.availableStock(),
|
||||
stockToRemit: this.stockToRemit(),
|
||||
remainingQuantityInStock: this.remainingQuantityInStock(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens a dialog to allow the user to change the remission quantity for the item.
|
||||
* Validates the input and updates the remission quantity in the store if valid.
|
||||
*
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async openRemissionQuantityDialog(): Promise<void> {
|
||||
const dialogRef = this.#dialog({
|
||||
title: 'Remi-Menge ändern',
|
||||
data: {
|
||||
message: 'Wie viele Exemplare können remittiert werden?',
|
||||
inputLabel: 'Remi-Menge',
|
||||
inputValidation: [
|
||||
{
|
||||
errorKey: 'required',
|
||||
inputValidator: Validators.required,
|
||||
errorText: 'Bitte geben Sie eine Menge an.',
|
||||
},
|
||||
{
|
||||
errorKey: 'pattern',
|
||||
inputValidator: Validators.pattern(/^[1-9][0-9]*$/),
|
||||
errorText: 'Die Menge muss mindestens 1 sein.',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
const itemId = this.item()?.id;
|
||||
const quantity = Number(result?.inputValue);
|
||||
|
||||
if (itemId && quantity > 0) {
|
||||
this.#store.updateRemissionQuantity(itemId, this.item(), quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
RemissionListType,
|
||||
RemissionSearchService,
|
||||
} from '@isa/remission/data-access';
|
||||
import {
|
||||
DropdownAppearance,
|
||||
DropdownButtonComponent,
|
||||
DropdownOptionComponent,
|
||||
} from '@isa/ui/input-controls';
|
||||
import { remissionListTypeRouteMapping } from './remission-list-type-route.mapping';
|
||||
import { injectRemissionListType } from '../injects/inject-remission-list-type';
|
||||
|
||||
@Component({
|
||||
selector: 'remi-feature-remission-list-select',
|
||||
templateUrl: './remission-list-select.component.html',
|
||||
styleUrl: './remission-list-select.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [DropdownButtonComponent, DropdownOptionComponent],
|
||||
})
|
||||
export class RemissionListSelectComponent {
|
||||
DropdownAppearance = DropdownAppearance;
|
||||
RemissionListCategory = RemissionListType;
|
||||
remissionSearchService = inject(RemissionSearchService);
|
||||
router = inject(Router);
|
||||
route = inject(ActivatedRoute);
|
||||
|
||||
remissionListTypes = this.remissionSearchService.remissionListType();
|
||||
selectedRemissionListType = injectRemissionListType();
|
||||
|
||||
async changeRemissionType(remissionTypeValue: RemissionListType | undefined) {
|
||||
console.log(remissionTypeValue, remissionListTypeRouteMapping);
|
||||
|
||||
if (
|
||||
!remissionTypeValue ||
|
||||
remissionTypeValue === RemissionListType.Koerperlos
|
||||
)
|
||||
return;
|
||||
|
||||
await this.router.navigate(
|
||||
[remissionListTypeRouteMapping[remissionTypeValue]],
|
||||
{
|
||||
relativeTo: this.route,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
RemissionListType,
|
||||
RemissionSearchService,
|
||||
} from '@isa/remission/data-access';
|
||||
import {
|
||||
DropdownAppearance,
|
||||
DropdownButtonComponent,
|
||||
DropdownOptionComponent,
|
||||
} from '@isa/ui/input-controls';
|
||||
import { remissionListTypeRouteMapping } from './remission-list-type-route.mapping';
|
||||
import { injectRemissionListType } from '../injects/inject-remission-list-type';
|
||||
|
||||
@Component({
|
||||
selector: 'remi-feature-remission-list-select',
|
||||
templateUrl: './remission-list-select.component.html',
|
||||
styleUrl: './remission-list-select.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [DropdownButtonComponent, DropdownOptionComponent],
|
||||
})
|
||||
export class RemissionListSelectComponent {
|
||||
DropdownAppearance = DropdownAppearance;
|
||||
RemissionListCategory = RemissionListType;
|
||||
remissionSearchService = inject(RemissionSearchService);
|
||||
router = inject(Router);
|
||||
route = inject(ActivatedRoute);
|
||||
|
||||
remissionListTypes = this.remissionSearchService.remissionListType();
|
||||
selectedRemissionListType = injectRemissionListType();
|
||||
|
||||
async changeRemissionType(remissionTypeValue: RemissionListType | undefined) {
|
||||
console.log(remissionTypeValue, remissionListTypeRouteMapping);
|
||||
|
||||
if (
|
||||
!remissionTypeValue ||
|
||||
remissionTypeValue === RemissionListType.Koerperlos
|
||||
)
|
||||
return;
|
||||
|
||||
await this.router.navigate(
|
||||
[remissionListTypeRouteMapping[remissionTypeValue]],
|
||||
{
|
||||
relativeTo: this.route,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
<remi-feature-remission-start-card></remi-feature-remission-start-card>
|
||||
|
||||
<remi-feature-remission-list-select></remi-feature-remission-list-select>
|
||||
|
||||
<filter-controls-panel (triggerSearch)="search()"></filter-controls-panel>
|
||||
|
||||
<span
|
||||
class="text-isa-neutral-900 isa-text-body-2-regular self-start"
|
||||
data-what="result-count"
|
||||
>
|
||||
{{ hits() }} Einträge
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full items-center justify-center">
|
||||
@for (item of items(); track item.id) {
|
||||
@defer (on viewport) {
|
||||
<a [routerLink]="['../', 'return', item.id]" class="w-full">
|
||||
<remi-feature-remission-list-item
|
||||
#listElement
|
||||
[item]="item"
|
||||
[stock]="getStockForItem(item)"
|
||||
[productGroupValue]="getProductGroupValueForItem(item)"
|
||||
></remi-feature-remission-list-item>
|
||||
</a>
|
||||
} @placeholder {
|
||||
<div class="h-[7.75rem] w-full flex items-center justify-center">
|
||||
<ui-icon-button
|
||||
[pending]="true"
|
||||
[color]="'tertiary'"
|
||||
data-what="load-spinner"
|
||||
data-which="item-placeholder"
|
||||
></ui-icon-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<remi-feature-remission-start-card></remi-feature-remission-start-card>
|
||||
|
||||
<remi-feature-remission-list-select></remi-feature-remission-list-select>
|
||||
|
||||
<filter-controls-panel (triggerSearch)="search()"></filter-controls-panel>
|
||||
|
||||
<span
|
||||
class="text-isa-neutral-900 isa-text-body-2-regular self-start"
|
||||
data-what="result-count"
|
||||
>
|
||||
{{ hits() }} Einträge
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full items-center justify-center">
|
||||
@for (item of items(); track item.id) {
|
||||
@defer (on viewport) {
|
||||
<a [routerLink]="['../', 'return', item.id]" class="w-full">
|
||||
<remi-feature-remission-list-item
|
||||
#listElement
|
||||
[item]="item"
|
||||
[stock]="getStockForItem(item)"
|
||||
[productGroupValue]="getProductGroupValueForItem(item)"
|
||||
></remi-feature-remission-list-item>
|
||||
</a>
|
||||
} @placeholder {
|
||||
<div class="h-[7.75rem] w-full flex items-center justify-center">
|
||||
<ui-icon-button
|
||||
[pending]="true"
|
||||
[color]="'tertiary'"
|
||||
data-what="load-spinner"
|
||||
data-which="item-placeholder"
|
||||
></ui-icon-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,219 +1,219 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import {
|
||||
provideFilter,
|
||||
withQuerySettingsFactory,
|
||||
withQueryParamsSync,
|
||||
FilterControlsPanelComponent,
|
||||
FilterService,
|
||||
} from '@isa/shared/filter';
|
||||
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
|
||||
import { RemissionStartCardComponent } from './remission-start-card/remission-start-card.component';
|
||||
import { RemissionListSelectComponent } from './remission-list-select/remission-list-select.component';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
createRemissionInStockResource,
|
||||
createRemissionListResource,
|
||||
createRemissionProductGroupResource,
|
||||
} from './resources';
|
||||
import { injectRemissionListType } from './injects/inject-remission-list-type';
|
||||
import { RemissionListItemComponent } from './remission-list-item/remission-list-item.component';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
import {
|
||||
ReturnItem,
|
||||
StockInfo,
|
||||
ReturnSuggestion,
|
||||
} from '@isa/remission/data-access';
|
||||
|
||||
function querySettingsFactory() {
|
||||
return inject(ActivatedRoute).snapshot.data['querySettings'];
|
||||
}
|
||||
|
||||
/**
|
||||
* RemissionListComponent
|
||||
*
|
||||
* Displays and manages a list of remission items with filtering and stock information.
|
||||
* Implements local state using Angular signals and computed properties.
|
||||
* Follows SOLID and Clean Code principles for maintainability and testability.
|
||||
*
|
||||
* @remarks
|
||||
* - Uses OnPush change detection for performance.
|
||||
* - All state is managed locally via signals.
|
||||
* - Filtering is handled via FilterService.
|
||||
* - Stock information is dynamically loaded for visible items.
|
||||
*
|
||||
* @see {@link https://angular.dev/style-guide} for Angular best practices.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'remi-feature-remission-list',
|
||||
templateUrl: './remission-list.component.html',
|
||||
styleUrl: './remission-list.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
provideFilter(
|
||||
withQuerySettingsFactory(querySettingsFactory),
|
||||
withQueryParamsSync(),
|
||||
),
|
||||
],
|
||||
imports: [
|
||||
RemissionStartCardComponent,
|
||||
FilterControlsPanelComponent,
|
||||
RemissionListSelectComponent,
|
||||
RemissionListItemComponent,
|
||||
RouterLink,
|
||||
IconButtonComponent,
|
||||
],
|
||||
host: {
|
||||
'[class]':
|
||||
'"w-full flex flex-col gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden"',
|
||||
},
|
||||
})
|
||||
export class RemissionListComponent {
|
||||
/**
|
||||
* Activated route instance for accessing route data and params.
|
||||
*/
|
||||
route = inject(ActivatedRoute);
|
||||
|
||||
/**
|
||||
* Signal for the current route URL segments.
|
||||
*/
|
||||
routeUrl = toSignal(this.route.url);
|
||||
|
||||
/**
|
||||
* FilterService instance for managing filter state and queries.
|
||||
* @private
|
||||
*/
|
||||
#filterService = inject(FilterService);
|
||||
|
||||
/**
|
||||
* Restores scroll position when navigating back to this component.
|
||||
*/
|
||||
restoreScrollPosition = injectRestoreScrollPosition();
|
||||
|
||||
/**
|
||||
* Signal containing the current route data snapshot.
|
||||
*/
|
||||
routeData = toSignal(this.route.data);
|
||||
|
||||
/**
|
||||
* Signal representing the currently selected remission list type.
|
||||
*/
|
||||
selectedRemissionListType = injectRemissionListType();
|
||||
|
||||
/**
|
||||
* Resource signal for fetching the remission list based on current filters.
|
||||
* @returns Remission list resource state.
|
||||
*/
|
||||
remissionResource = createRemissionListResource(() => {
|
||||
return {
|
||||
remissionListType: this.selectedRemissionListType(),
|
||||
queryToken: this.#filterService.query(),
|
||||
};
|
||||
});
|
||||
|
||||
// TODO (Info): Bei Add Item und
|
||||
// Bei remittieren eines Stapels die StockInformation für alle anderen Stapel mit der selben EAN
|
||||
// Muss InStock nochmal aufgerufen werden um die StockInformationen zu aktualisieren
|
||||
/**
|
||||
* Resource signal for fetching stock information for the current remission items.
|
||||
* Updates when the list of items changes.
|
||||
* @returns Stock info resource state.
|
||||
*/
|
||||
inStockResource = createRemissionInStockResource(() => {
|
||||
return {
|
||||
itemIds: this.items()
|
||||
.map((item) => item?.product?.catalogProductNumber)
|
||||
.filter(
|
||||
(catalogProductNumber): catalogProductNumber is string =>
|
||||
typeof catalogProductNumber === 'string',
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Resource signal for fetching product group information based on current remission items.
|
||||
* Updates when the remission list changes.
|
||||
* @returns Product group resource state.
|
||||
*/
|
||||
productGroupResource = createRemissionProductGroupResource();
|
||||
|
||||
/**
|
||||
* Computed signal for the current remission list response.
|
||||
* @returns The latest remission list response or undefined.
|
||||
*/
|
||||
listResponseValue = computed(() => this.remissionResource.value());
|
||||
|
||||
/**
|
||||
* Computed signal for the current in-stock response.
|
||||
* @returns Array of StockInfo or undefined.
|
||||
*/
|
||||
inStockResponseValue = computed(() => this.inStockResource.value());
|
||||
|
||||
/**
|
||||
* Computed signal for the product group response.
|
||||
* @returns Array of KeyValueStringAndString or undefined.
|
||||
*/
|
||||
productGroupResponseValue = computed(() => this.productGroupResource.value());
|
||||
|
||||
/**
|
||||
* Computed signal for the remission items to display.
|
||||
* @returns Array of ReturnItem or ReturnSuggestion.
|
||||
*/
|
||||
items = computed(() => {
|
||||
const value = this.listResponseValue();
|
||||
return value?.result ? value.result : [];
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed signal for the total number of hits in the remission list.
|
||||
* @returns Number of hits, or 0 if unavailable.
|
||||
*/
|
||||
hits = computed(() => {
|
||||
const value = this.listResponseValue();
|
||||
return value?.hits ? value.hits : 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed signal mapping item IDs to their StockInfo.
|
||||
* @returns Map of itemId to StockInfo.
|
||||
*/
|
||||
stockInfoMap = computed(() => {
|
||||
const infos = this.inStockResponseValue() ?? [];
|
||||
return new Map(infos.map((info) => [info.itemId, info]));
|
||||
});
|
||||
|
||||
/**
|
||||
* Commits the current filter state and triggers a new search.
|
||||
*/
|
||||
search(): void {
|
||||
this.#filterService.commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the StockInfo for a given item.
|
||||
* @param item - The ReturnItem or ReturnSuggestion to look up.
|
||||
* @returns The StockInfo for the item, or undefined if not found.
|
||||
*/
|
||||
getStockForItem(item: ReturnItem | ReturnSuggestion): StockInfo | undefined {
|
||||
return this.stockInfoMap().get(Number(item?.product?.catalogProductNumber));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the product group value for a given item.
|
||||
* @param item - The ReturnItem or ReturnSuggestion to look up.
|
||||
* @returns The product group value as a string, or undefined if not found.
|
||||
*/
|
||||
getProductGroupValueForItem(
|
||||
item: ReturnItem | ReturnSuggestion,
|
||||
): string | undefined {
|
||||
const productGroup = this.productGroupResponseValue()?.find(
|
||||
(group) => group.key === item?.product?.productGroup,
|
||||
);
|
||||
return productGroup ? productGroup.value : '';
|
||||
}
|
||||
}
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import {
|
||||
provideFilter,
|
||||
withQuerySettingsFactory,
|
||||
withQueryParamsSync,
|
||||
FilterControlsPanelComponent,
|
||||
FilterService,
|
||||
} from '@isa/shared/filter';
|
||||
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
|
||||
import { RemissionStartCardComponent } from './remission-start-card/remission-start-card.component';
|
||||
import { RemissionListSelectComponent } from './remission-list-select/remission-list-select.component';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
createRemissionInStockResource,
|
||||
createRemissionListResource,
|
||||
createRemissionProductGroupResource,
|
||||
} from './resources';
|
||||
import { injectRemissionListType } from './injects/inject-remission-list-type';
|
||||
import { RemissionListItemComponent } from './remission-list-item/remission-list-item.component';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
import {
|
||||
ReturnItem,
|
||||
StockInfo,
|
||||
ReturnSuggestion,
|
||||
} from '@isa/remission/data-access';
|
||||
|
||||
function querySettingsFactory() {
|
||||
return inject(ActivatedRoute).snapshot.data['querySettings'];
|
||||
}
|
||||
|
||||
/**
|
||||
* RemissionListComponent
|
||||
*
|
||||
* Displays and manages a list of remission items with filtering and stock information.
|
||||
* Implements local state using Angular signals and computed properties.
|
||||
* Follows SOLID and Clean Code principles for maintainability and testability.
|
||||
*
|
||||
* @remarks
|
||||
* - Uses OnPush change detection for performance.
|
||||
* - All state is managed locally via signals.
|
||||
* - Filtering is handled via FilterService.
|
||||
* - Stock information is dynamically loaded for visible items.
|
||||
*
|
||||
* @see {@link https://angular.dev/style-guide} for Angular best practices.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'remi-feature-remission-list',
|
||||
templateUrl: './remission-list.component.html',
|
||||
styleUrl: './remission-list.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
provideFilter(
|
||||
withQuerySettingsFactory(querySettingsFactory),
|
||||
withQueryParamsSync(),
|
||||
),
|
||||
],
|
||||
imports: [
|
||||
RemissionStartCardComponent,
|
||||
FilterControlsPanelComponent,
|
||||
RemissionListSelectComponent,
|
||||
RemissionListItemComponent,
|
||||
RouterLink,
|
||||
IconButtonComponent,
|
||||
],
|
||||
host: {
|
||||
'[class]':
|
||||
'"w-full flex flex-col gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden"',
|
||||
},
|
||||
})
|
||||
export class RemissionListComponent {
|
||||
/**
|
||||
* Activated route instance for accessing route data and params.
|
||||
*/
|
||||
route = inject(ActivatedRoute);
|
||||
|
||||
/**
|
||||
* Signal for the current route URL segments.
|
||||
*/
|
||||
routeUrl = toSignal(this.route.url);
|
||||
|
||||
/**
|
||||
* FilterService instance for managing filter state and queries.
|
||||
* @private
|
||||
*/
|
||||
#filterService = inject(FilterService);
|
||||
|
||||
/**
|
||||
* Restores scroll position when navigating back to this component.
|
||||
*/
|
||||
restoreScrollPosition = injectRestoreScrollPosition();
|
||||
|
||||
/**
|
||||
* Signal containing the current route data snapshot.
|
||||
*/
|
||||
routeData = toSignal(this.route.data);
|
||||
|
||||
/**
|
||||
* Signal representing the currently selected remission list type.
|
||||
*/
|
||||
selectedRemissionListType = injectRemissionListType();
|
||||
|
||||
/**
|
||||
* Resource signal for fetching the remission list based on current filters.
|
||||
* @returns Remission list resource state.
|
||||
*/
|
||||
remissionResource = createRemissionListResource(() => {
|
||||
return {
|
||||
remissionListType: this.selectedRemissionListType(),
|
||||
queryToken: this.#filterService.query(),
|
||||
};
|
||||
});
|
||||
|
||||
// TODO (Info): Bei Add Item und
|
||||
// Bei remittieren eines Stapels die StockInformation für alle anderen Stapel mit der selben EAN
|
||||
// Muss InStock nochmal aufgerufen werden um die StockInformationen zu aktualisieren
|
||||
/**
|
||||
* Resource signal for fetching stock information for the current remission items.
|
||||
* Updates when the list of items changes.
|
||||
* @returns Stock info resource state.
|
||||
*/
|
||||
inStockResource = createRemissionInStockResource(() => {
|
||||
return {
|
||||
itemIds: this.items()
|
||||
.map((item) => item?.product?.catalogProductNumber)
|
||||
.filter(
|
||||
(catalogProductNumber): catalogProductNumber is string =>
|
||||
typeof catalogProductNumber === 'string',
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Resource signal for fetching product group information based on current remission items.
|
||||
* Updates when the remission list changes.
|
||||
* @returns Product group resource state.
|
||||
*/
|
||||
productGroupResource = createRemissionProductGroupResource();
|
||||
|
||||
/**
|
||||
* Computed signal for the current remission list response.
|
||||
* @returns The latest remission list response or undefined.
|
||||
*/
|
||||
listResponseValue = computed(() => this.remissionResource.value());
|
||||
|
||||
/**
|
||||
* Computed signal for the current in-stock response.
|
||||
* @returns Array of StockInfo or undefined.
|
||||
*/
|
||||
inStockResponseValue = computed(() => this.inStockResource.value());
|
||||
|
||||
/**
|
||||
* Computed signal for the product group response.
|
||||
* @returns Array of KeyValueStringAndString or undefined.
|
||||
*/
|
||||
productGroupResponseValue = computed(() => this.productGroupResource.value());
|
||||
|
||||
/**
|
||||
* Computed signal for the remission items to display.
|
||||
* @returns Array of ReturnItem or ReturnSuggestion.
|
||||
*/
|
||||
items = computed(() => {
|
||||
const value = this.listResponseValue();
|
||||
return value?.result ? value.result : [];
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed signal for the total number of hits in the remission list.
|
||||
* @returns Number of hits, or 0 if unavailable.
|
||||
*/
|
||||
hits = computed(() => {
|
||||
const value = this.listResponseValue();
|
||||
return value?.hits ? value.hits : 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed signal mapping item IDs to their StockInfo.
|
||||
* @returns Map of itemId to StockInfo.
|
||||
*/
|
||||
stockInfoMap = computed(() => {
|
||||
const infos = this.inStockResponseValue() ?? [];
|
||||
return new Map(infos.map((info) => [info.itemId, info]));
|
||||
});
|
||||
|
||||
/**
|
||||
* Commits the current filter state and triggers a new search.
|
||||
*/
|
||||
search(): void {
|
||||
this.#filterService.commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the StockInfo for a given item.
|
||||
* @param item - The ReturnItem or ReturnSuggestion to look up.
|
||||
* @returns The StockInfo for the item, or undefined if not found.
|
||||
*/
|
||||
getStockForItem(item: ReturnItem | ReturnSuggestion): StockInfo | undefined {
|
||||
return this.stockInfoMap().get(Number(item?.product?.catalogProductNumber));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the product group value for a given item.
|
||||
* @param item - The ReturnItem or ReturnSuggestion to look up.
|
||||
* @returns The product group value as a string, or undefined if not found.
|
||||
*/
|
||||
getProductGroupValueForItem(
|
||||
item: ReturnItem | ReturnSuggestion,
|
||||
): string | undefined {
|
||||
const productGroup = this.productGroupResponseValue()?.find(
|
||||
(group) => group.key === item?.product?.productGroup,
|
||||
);
|
||||
return productGroup ? productGroup.value : '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<ui-stateful-button
|
||||
[(state)]="state"
|
||||
defaultContent="Remittieren"
|
||||
successContent="Hinzugefügt"
|
||||
errorContent="Konnte nicht hinzugefügt werden."
|
||||
errorAction="Noch mal versuchen"
|
||||
defaultWidth="10rem"
|
||||
successWidth="20.375rem"
|
||||
errorWidth="32rem"
|
||||
[pending]="isLoading()"
|
||||
color="brand"
|
||||
size="large"
|
||||
class="remit-button"
|
||||
(clicked)="clickHandler()"
|
||||
(action)="retryHandler()"
|
||||
/>
|
||||
@@ -0,0 +1 @@
|
||||
// Component now uses ui-stateful-button which handles all styling
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
signal,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons';
|
||||
|
||||
@Component({
|
||||
selector: 'remi-remit-button',
|
||||
templateUrl: './remit-button.component.html',
|
||||
styleUrls: ['./remit-button.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [StatefulButtonComponent],
|
||||
})
|
||||
export class RemitButtonComponent implements OnDestroy {
|
||||
state = signal<StatefulButtonState>('default');
|
||||
isLoading = signal<boolean>(false);
|
||||
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
clickHandler() {
|
||||
// Clear any existing timer to prevent multiple clicks from stacking
|
||||
this.clearTimer();
|
||||
|
||||
this.isLoading.set(true);
|
||||
this.timer = setTimeout(() => {
|
||||
this.isLoading.set(false);
|
||||
// Simulate an async operation, e.g., API call
|
||||
const success = Math.random() > 0.5; // Randomly succeed or fail
|
||||
if (success) {
|
||||
this.state.set('success');
|
||||
} else {
|
||||
this.state.set('error');
|
||||
}
|
||||
}, 100); // Simulate async operation
|
||||
}
|
||||
|
||||
retryHandler() {
|
||||
this.isLoading.set(true);
|
||||
this.timer = setTimeout(() => {
|
||||
this.isLoading.set(false);
|
||||
this.state.set('success');
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
162
libs/ui/bullet-list/src/lib/bullet-list-item.component.spec.ts
Normal file
162
libs/ui/bullet-list/src/lib/bullet-list-item.component.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Component } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { BulletListItemComponent } from './bullet-list-item.component';
|
||||
import { BulletListComponent } from './bullet-list.component';
|
||||
import { isaActionChevronRight } from '@isa/icons';
|
||||
|
||||
// Mock icons for testing
|
||||
const testIcons = {
|
||||
isaActionChevronRight,
|
||||
customIcon: '<svg></svg>',
|
||||
ownIcon: '<svg></svg>',
|
||||
parentIcon: '<svg></svg>',
|
||||
childIcon: '<svg></svg>',
|
||||
newParentIcon: '<svg></svg>',
|
||||
};
|
||||
|
||||
// Test host component without parent BulletListComponent
|
||||
@Component({
|
||||
template: `
|
||||
<ui-bullet-list-item [icon]="testIcon">
|
||||
<span data-what="item-content">Test item content</span>
|
||||
</ui-bullet-list-item>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [BulletListItemComponent],
|
||||
providers: [provideIcons(testIcons)],
|
||||
})
|
||||
class TestHostComponent {
|
||||
testIcon: string | undefined = undefined;
|
||||
}
|
||||
|
||||
// Test host component with parent BulletListComponent
|
||||
@Component({
|
||||
template: `
|
||||
<ui-bullet-list [icon]="parentIcon">
|
||||
<ui-bullet-list-item [icon]="childIcon">
|
||||
<span data-what="item-content">Test item content</span>
|
||||
</ui-bullet-list-item>
|
||||
</ui-bullet-list>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [BulletListComponent, BulletListItemComponent],
|
||||
})
|
||||
class TestHostWithParentComponent {
|
||||
parentIcon = 'parentIcon';
|
||||
childIcon: string | undefined = undefined;
|
||||
}
|
||||
|
||||
describe('BulletListItemComponent', () => {
|
||||
let component: BulletListItemComponent;
|
||||
let fixture: ComponentFixture<BulletListItemComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BulletListItemComponent],
|
||||
providers: [provideIcons(testIcons)],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BulletListItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have undefined icon by default', () => {
|
||||
expect(component.icon()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept custom icon input', () => {
|
||||
fixture.componentRef.setInput('icon', 'customIcon');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.icon()).toBe('customIcon');
|
||||
});
|
||||
|
||||
it('should render with correct CSS class', () => {
|
||||
const hostElement = fixture.nativeElement;
|
||||
expect(hostElement.classList.contains('ui-bullet-list-item')).toBe(true);
|
||||
});
|
||||
|
||||
it('should render ng-icon with correct size', () => {
|
||||
const ngIcon = fixture.debugElement.query(By.directive(NgIcon));
|
||||
expect(ngIcon).toBeTruthy();
|
||||
expect(ngIcon.nativeElement.getAttribute('size')).toBe('1.5rem');
|
||||
});
|
||||
|
||||
it('should use undefined iconName when no icon is provided and no parent exists', () => {
|
||||
expect(component.iconName()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use own icon when provided', () => {
|
||||
fixture.componentRef.setInput('icon', 'ownIcon');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.iconName()).toBe('ownIcon');
|
||||
});
|
||||
|
||||
it('should project content correctly', async () => {
|
||||
const hostFixture = TestBed.createComponent(TestHostComponent);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const projectedContent = hostFixture.nativeElement.querySelector('[data-what="item-content"]');
|
||||
expect(projectedContent).toBeTruthy();
|
||||
expect(projectedContent.textContent.trim()).toBe('Test item content');
|
||||
});
|
||||
|
||||
it('should inherit parent icon when no own icon is provided', async () => {
|
||||
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const bulletListItem = hostFixture.debugElement.query(
|
||||
sel => sel.componentInstance instanceof BulletListItemComponent
|
||||
).componentInstance;
|
||||
|
||||
expect(bulletListItem.iconName()).toBe('parentIcon');
|
||||
});
|
||||
|
||||
it('should use own icon instead of parent icon when both are provided', async () => {
|
||||
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
|
||||
const hostComponent = hostFixture.componentInstance;
|
||||
hostComponent.childIcon = 'childIcon';
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const bulletListItem = hostFixture.debugElement.query(
|
||||
sel => sel.componentInstance instanceof BulletListItemComponent
|
||||
).componentInstance;
|
||||
|
||||
expect(bulletListItem.iconName()).toBe('childIcon');
|
||||
});
|
||||
|
||||
it('should render ng-icon with computed iconName', async () => {
|
||||
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const ngIcon = hostFixture.debugElement.query(By.directive(NgIcon));
|
||||
expect(ngIcon.componentInstance.name()).toBe('parentIcon');
|
||||
});
|
||||
|
||||
it('should update icon when parent icon changes', async () => {
|
||||
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
|
||||
const hostComponent = hostFixture.componentInstance;
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const bulletListItem = hostFixture.debugElement.query(
|
||||
sel => sel.componentInstance instanceof BulletListItemComponent
|
||||
).componentInstance;
|
||||
|
||||
expect(bulletListItem.iconName()).toBe('parentIcon');
|
||||
|
||||
// Change parent icon
|
||||
hostComponent.parentIcon = 'newParentIcon';
|
||||
hostFixture.detectChanges();
|
||||
|
||||
expect(bulletListItem.iconName()).toBe('newParentIcon');
|
||||
});
|
||||
});
|
||||
85
libs/ui/bullet-list/src/lib/bullet-list.component.spec.ts
Normal file
85
libs/ui/bullet-list/src/lib/bullet-list.component.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Component } from '@angular/core';
|
||||
import { provideIcons } from '@ng-icons/core';
|
||||
import { BulletListComponent } from './bullet-list.component';
|
||||
import { isaActionChevronRight } from '@isa/icons';
|
||||
|
||||
// Mock icons for testing
|
||||
const testIcons = {
|
||||
isaActionChevronRight,
|
||||
customIcon: '<svg></svg>',
|
||||
customTestIcon: '<svg></svg>',
|
||||
};
|
||||
|
||||
// Test host component to verify content projection
|
||||
@Component({
|
||||
template: `
|
||||
<ui-bullet-list [icon]="testIcon">
|
||||
<div data-what="test-content">Test content</div>
|
||||
</ui-bullet-list>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [BulletListComponent],
|
||||
})
|
||||
class TestHostComponent {
|
||||
testIcon = 'testIcon';
|
||||
}
|
||||
|
||||
describe('BulletListComponent', () => {
|
||||
let component: BulletListComponent;
|
||||
let fixture: ComponentFixture<BulletListComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BulletListComponent],
|
||||
providers: [provideIcons(testIcons)],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BulletListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have default icon value', () => {
|
||||
expect(component.icon()).toBe('isaActionChevronRight');
|
||||
});
|
||||
|
||||
it('should accept custom icon input', () => {
|
||||
fixture.componentRef.setInput('icon', 'customIcon');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.icon()).toBe('customIcon');
|
||||
});
|
||||
|
||||
it('should render with correct CSS class', () => {
|
||||
const hostElement = fixture.nativeElement;
|
||||
expect(hostElement.classList.contains('ui-bullet-list')).toBe(true);
|
||||
});
|
||||
|
||||
it('should project content correctly', async () => {
|
||||
const hostFixture = TestBed.createComponent(TestHostComponent);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const projectedContent = hostFixture.nativeElement.querySelector('[data-what="test-content"]');
|
||||
expect(projectedContent).toBeTruthy();
|
||||
expect(projectedContent.textContent.trim()).toBe('Test content');
|
||||
});
|
||||
|
||||
it('should pass icon to child components via dependency injection', async () => {
|
||||
const hostFixture = TestBed.createComponent(TestHostComponent);
|
||||
const hostComponent = hostFixture.componentInstance;
|
||||
hostComponent.testIcon = 'customTestIcon';
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const bulletListComponent = hostFixture.debugElement.query(
|
||||
sel => sel.componentInstance instanceof BulletListComponent
|
||||
).componentInstance;
|
||||
|
||||
expect(bulletListComponent.icon()).toBe('customTestIcon');
|
||||
});
|
||||
});
|
||||
@@ -2,3 +2,4 @@
|
||||
@use "lib/icon-button";
|
||||
@use "lib/info-button";
|
||||
@use "lib/text-button";
|
||||
@use "lib/stateful-button/stateful-button";
|
||||
|
||||
@@ -2,4 +2,5 @@ export * from './lib/button.component';
|
||||
export * from './lib/icon-button.component';
|
||||
export * from './lib/info-button.component';
|
||||
export * from './lib/text-button.component';
|
||||
export * from './lib/stateful-button/stateful-button.component';
|
||||
export * from './lib/types';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.stateful-button {
|
||||
@apply flex items-center justify-center select-none;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
.stateful-button-content {
|
||||
@apply flex items-center gap-1 w-full;
|
||||
white-space: nowrap;
|
||||
|
||||
&--default {
|
||||
@apply justify-center;
|
||||
}
|
||||
&--error {
|
||||
@apply justify-between;
|
||||
}
|
||||
&--success {
|
||||
@apply justify-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stateful-button-action {
|
||||
@apply cursor-pointer font-bold;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<ui-button
|
||||
[class]="['stateful-button', stateClass()]"
|
||||
[color]="color()"
|
||||
[size]="size()"
|
||||
[pending]="pending()"
|
||||
(click)="handleButtonClick()"
|
||||
data-what="stateful-button"
|
||||
[attr.data-which]="state()"
|
||||
>
|
||||
@switch (state()) {
|
||||
@case ('default') {
|
||||
<div
|
||||
class="stateful-button-content stateful-button-content--default"
|
||||
[@fade]
|
||||
>
|
||||
<span>{{ defaultContent() }}</span>
|
||||
</div>
|
||||
}
|
||||
@case ('success') {
|
||||
<div
|
||||
class="stateful-button-content stateful-button-content--success"
|
||||
[@fade]
|
||||
>
|
||||
<ng-icon name="isaActionCheck" size="1.5rem"></ng-icon>
|
||||
<span>{{ successContent() }}</span>
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<div
|
||||
class="stateful-button-content stateful-button-content--error"
|
||||
[@fade]
|
||||
>
|
||||
<span class="font-normal">{{ errorContent() }}</span>
|
||||
@if (errorAction()) {
|
||||
<span
|
||||
class="stateful-button-action"
|
||||
(click)="handleActionClick($event)"
|
||||
(keydown.enter)="handleActionClick($event)"
|
||||
(keydown.space)="handleActionClick($event)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
data-what="stateful-button-action"
|
||||
>
|
||||
{{ errorAction() }}
|
||||
</span>
|
||||
} @else {
|
||||
<span></span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</ui-button>
|
||||
@@ -0,0 +1,227 @@
|
||||
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
|
||||
import { StatefulButtonComponent } from './stateful-button.component';
|
||||
import { fakeAsync, tick } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
|
||||
describe('StatefulButtonComponent', () => {
|
||||
let spectator: Spectator<StatefulButtonComponent>;
|
||||
const createComponent = createComponentFactory({
|
||||
component: StatefulButtonComponent,
|
||||
imports: [NoopAnimationsModule],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spectator = createComponent({
|
||||
props: {
|
||||
defaultContent: 'Submit',
|
||||
successContent: 'Submitted',
|
||||
errorContent: 'Failed to submit',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(spectator.component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display default content initially', () => {
|
||||
expect(spectator.query('.stateful-button-content--default')).toHaveText('Submit');
|
||||
expect(spectator.component.state()).toBe('default');
|
||||
});
|
||||
|
||||
it('should have correct e2e attributes', () => {
|
||||
const button = spectator.query('ui-button');
|
||||
expect(button).toHaveAttribute('data-what', 'stateful-button');
|
||||
expect(button).toHaveAttribute('data-which', 'default');
|
||||
});
|
||||
|
||||
it('should apply correct CSS classes based on state', () => {
|
||||
expect(spectator.query('.stateful-button-content--default')).toExist();
|
||||
|
||||
spectator.component.state.set('success');
|
||||
spectator.detectChanges();
|
||||
expect(spectator.query('.stateful-button-content--success')).toExist();
|
||||
|
||||
spectator.component.state.set('error');
|
||||
spectator.detectChanges();
|
||||
expect(spectator.query('.stateful-button-content--error')).toExist();
|
||||
});
|
||||
|
||||
it('should emit clicked event when clicked in default state', () => {
|
||||
const clickedSpy = jest.fn();
|
||||
spectator.output('clicked').subscribe(clickedSpy);
|
||||
|
||||
spectator.click('ui-button');
|
||||
|
||||
expect(clickedSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should transition from success to default when clicked', () => {
|
||||
spectator.component.state.set('success');
|
||||
spectator.detectChanges();
|
||||
|
||||
expect(spectator.component.state()).toBe('success');
|
||||
expect(spectator.query('.stateful-button-content--success')).toContainText('Submitted');
|
||||
|
||||
spectator.click('ui-button');
|
||||
|
||||
expect(spectator.component.state()).toBe('default');
|
||||
});
|
||||
|
||||
it('should transition from error to default when clicked', () => {
|
||||
spectator.component.state.set('error');
|
||||
spectator.detectChanges();
|
||||
|
||||
expect(spectator.component.state()).toBe('error');
|
||||
|
||||
spectator.click('ui-button');
|
||||
|
||||
expect(spectator.component.state()).toBe('default');
|
||||
});
|
||||
|
||||
it('should display success icon and content', () => {
|
||||
spectator.component.state.set('success');
|
||||
spectator.detectChanges();
|
||||
|
||||
expect(spectator.query('ng-icon')).toExist();
|
||||
expect(spectator.query('ng-icon')).toHaveAttribute('name', 'isaActionCheck');
|
||||
expect(spectator.query('.stateful-button-content--success')).toContainText('Submitted');
|
||||
});
|
||||
|
||||
it('should display error content with action button', () => {
|
||||
spectator.setInput('errorAction', 'Try again');
|
||||
spectator.component.state.set('error');
|
||||
spectator.detectChanges();
|
||||
|
||||
expect(spectator.query('.stateful-button-content--error')).toContainText('Failed to submit');
|
||||
expect(spectator.query('.stateful-button-action')).toHaveText('Try again');
|
||||
});
|
||||
|
||||
it('should emit action event when error action is clicked', () => {
|
||||
spectator.setInput('errorAction', 'Try again');
|
||||
spectator.component.state.set('error');
|
||||
spectator.detectChanges();
|
||||
|
||||
const actionSpy = jest.fn();
|
||||
spectator.output('action').subscribe(actionSpy);
|
||||
|
||||
spectator.click('.stateful-button-action');
|
||||
|
||||
expect(actionSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should display error content without action when errorAction is not provided', () => {
|
||||
spectator.component.state.set('error');
|
||||
spectator.detectChanges();
|
||||
|
||||
expect(spectator.query('.stateful-button-content--error')).toContainText('Failed to submit');
|
||||
expect(spectator.query('.stateful-button-action')).not.toExist();
|
||||
});
|
||||
|
||||
it('should pass through button properties correctly', () => {
|
||||
spectator.setInput('color', 'brand');
|
||||
spectator.setInput('size', 'large');
|
||||
spectator.setInput('pending', true);
|
||||
spectator.detectChanges();
|
||||
|
||||
const button = spectator.query('ui-button');
|
||||
expect(button).toHaveClass('ui-button__brand');
|
||||
expect(button).toHaveClass('ui-button__large');
|
||||
expect(button).toHaveClass('ui-button__pending');
|
||||
});
|
||||
|
||||
it('should auto-dismiss success state after timeout', fakeAsync(() => {
|
||||
spectator.setInput('dismiss', 1000);
|
||||
spectator.component.state.set('success');
|
||||
spectator.detectChanges();
|
||||
|
||||
expect(spectator.component.state()).toBe('success');
|
||||
|
||||
tick(1000);
|
||||
|
||||
expect(spectator.component.state()).toBe('default');
|
||||
}));
|
||||
|
||||
it('should use correct widths for each state', () => {
|
||||
// Setup the expected widths
|
||||
spectator.setInput('defaultWidth', '10rem');
|
||||
spectator.setInput('successWidth', '20.375rem');
|
||||
spectator.setInput('errorWidth', '32rem');
|
||||
|
||||
expect(spectator.component.widthStyle()).toBe('10rem');
|
||||
|
||||
spectator.component.state.set('success');
|
||||
expect(spectator.component.widthStyle()).toBe('20.375rem');
|
||||
|
||||
spectator.component.state.set('error');
|
||||
expect(spectator.component.widthStyle()).toBe('32rem');
|
||||
});
|
||||
|
||||
it('should use custom widths when provided', () => {
|
||||
spectator.setInput('defaultWidth', '8rem');
|
||||
spectator.setInput('successWidth', '16rem');
|
||||
spectator.setInput('errorWidth', '24rem');
|
||||
|
||||
expect(spectator.component.widthStyle()).toBe('8rem');
|
||||
|
||||
spectator.component.state.set('success');
|
||||
expect(spectator.component.widthStyle()).toBe('16rem');
|
||||
|
||||
spectator.component.state.set('error');
|
||||
expect(spectator.component.widthStyle()).toBe('24rem');
|
||||
});
|
||||
|
||||
it('should update e2e data-which attribute based on state', () => {
|
||||
const button = spectator.query('ui-button');
|
||||
|
||||
expect(button).toHaveAttribute('data-which', 'default');
|
||||
|
||||
spectator.component.state.set('success');
|
||||
spectator.detectChanges();
|
||||
expect(button).toHaveAttribute('data-which', 'success');
|
||||
|
||||
spectator.component.state.set('error');
|
||||
spectator.detectChanges();
|
||||
expect(button).toHaveAttribute('data-which', 'error');
|
||||
});
|
||||
|
||||
it('should handle keyboard accessibility on action button', () => {
|
||||
spectator.setInput('errorAction', 'Try again');
|
||||
spectator.component.state.set('error');
|
||||
spectator.detectChanges();
|
||||
|
||||
const action = spectator.query('.stateful-button-action');
|
||||
expect(action).toHaveAttribute('role', 'button');
|
||||
expect(action).toHaveAttribute('tabindex', '0');
|
||||
expect(action).toHaveAttribute('data-what', 'stateful-button-action');
|
||||
});
|
||||
|
||||
it('should have fade animation applied to content divs', () => {
|
||||
// Animation triggers don't add DOM attributes, so we test for the elements to exist
|
||||
// The animation setup is tested through the NoopAnimationsModule in the component setup
|
||||
expect(spectator.query('.stateful-button-content--default')).toExist();
|
||||
|
||||
spectator.component.state.set('success');
|
||||
spectator.detectChanges();
|
||||
expect(spectator.query('.stateful-button-content--success')).toExist();
|
||||
|
||||
spectator.component.state.set('error');
|
||||
spectator.detectChanges();
|
||||
expect(spectator.query('.stateful-button-content--error')).toExist();
|
||||
});
|
||||
|
||||
it('should properly align content based on state', () => {
|
||||
const defaultContent = spectator.query('.stateful-button-content--default');
|
||||
expect(defaultContent).toHaveClass('stateful-button-content--default');
|
||||
|
||||
spectator.component.state.set('success');
|
||||
spectator.detectChanges();
|
||||
const successContent = spectator.query('.stateful-button-content--success');
|
||||
expect(successContent).toHaveClass('stateful-button-content--success');
|
||||
|
||||
spectator.component.state.set('error');
|
||||
spectator.detectChanges();
|
||||
const errorContent = spectator.query('.stateful-button-content--error');
|
||||
expect(errorContent).toHaveClass('stateful-button-content--error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
model,
|
||||
output,
|
||||
effect,
|
||||
OnDestroy,
|
||||
inject,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { ButtonComponent } from '../button.component';
|
||||
import { ButtonColor, ButtonSize } from '../types';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionCheck } from '@isa/icons';
|
||||
import {
|
||||
animate,
|
||||
AnimationBuilder,
|
||||
style,
|
||||
transition,
|
||||
trigger,
|
||||
} from '@angular/animations';
|
||||
|
||||
export type StatefulButtonState = 'default' | 'success' | 'error';
|
||||
|
||||
@Component({
|
||||
selector: 'ui-stateful-button',
|
||||
templateUrl: './stateful-button.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [ButtonComponent, NgIcon],
|
||||
providers: [provideIcons({ isaActionCheck })],
|
||||
animations: [
|
||||
trigger('fade', [
|
||||
transition(':enter', [
|
||||
style({ opacity: 0.5 }),
|
||||
animate('125ms 125ms ease-in', style({ opacity: 1 })),
|
||||
]),
|
||||
transition(':leave', [
|
||||
style({ opacity: '*' }),
|
||||
animate('124ms ease-out', style({ opacity: 0.5 })),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class StatefulButtonComponent implements OnDestroy {
|
||||
#animationBuilder = inject(AnimationBuilder);
|
||||
private buttonElement = viewChild.required(ButtonComponent, {
|
||||
read: ElementRef,
|
||||
});
|
||||
|
||||
// State management
|
||||
state = model<StatefulButtonState>('default');
|
||||
|
||||
stateEffect = effect(() => {
|
||||
this.state();
|
||||
|
||||
untracked(() => {
|
||||
this.#makeWidthAnimation(this.widthStyle());
|
||||
});
|
||||
});
|
||||
|
||||
// Content inputs for each state
|
||||
defaultContent = input.required<string>();
|
||||
successContent = input.required<string>();
|
||||
errorContent = input.required<string>();
|
||||
errorAction = input<string>();
|
||||
|
||||
// Width configuration for each state
|
||||
defaultWidth = input<string>('100%');
|
||||
successWidth = input<string>('100%');
|
||||
errorWidth = input<string>('100%');
|
||||
|
||||
// Optional dismiss timeout in milliseconds
|
||||
dismiss = input<number>();
|
||||
|
||||
// Button properties
|
||||
color = input<ButtonColor>('primary');
|
||||
size = input<ButtonSize>('medium');
|
||||
pending = input<boolean>(false);
|
||||
|
||||
// Output events
|
||||
clicked = output<void>();
|
||||
action = output<void>();
|
||||
|
||||
// Internal state
|
||||
private dismissTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Computed properties
|
||||
stateClass = computed(() => `stateful-button--${this.state()}`);
|
||||
|
||||
widthStyle = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'success':
|
||||
return this.successWidth();
|
||||
case 'error':
|
||||
return this.errorWidth();
|
||||
default:
|
||||
return this.defaultWidth();
|
||||
}
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Watch for state changes to handle auto-dismiss
|
||||
this.setupStateEffect();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearDismissTimer();
|
||||
}
|
||||
|
||||
private setupStateEffect(): void {
|
||||
// Use effect to watch state changes
|
||||
effect(() => {
|
||||
const currentState = this.state();
|
||||
const dismissTimeout = this.dismiss();
|
||||
|
||||
// Clear any existing timer
|
||||
this.clearDismissTimer();
|
||||
|
||||
// Set up auto-dismiss if configured
|
||||
if (
|
||||
dismissTimeout &&
|
||||
(currentState === 'success' || currentState === 'error')
|
||||
) {
|
||||
this.dismissTimer = setTimeout(() => {
|
||||
this.changeState('default');
|
||||
}, dismissTimeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleButtonClick(): void {
|
||||
const currentState = this.state();
|
||||
|
||||
if (currentState === 'default') {
|
||||
// Only emit click event in default state
|
||||
this.clicked.emit();
|
||||
} else if (currentState === 'success' || currentState === 'error') {
|
||||
// In success/error states, clicking the button returns to default
|
||||
this.changeState('default');
|
||||
}
|
||||
}
|
||||
|
||||
handleActionClick(event: Event): void {
|
||||
// Prevent button click from firing
|
||||
event.stopPropagation();
|
||||
|
||||
// Emit action event
|
||||
this.action.emit();
|
||||
}
|
||||
|
||||
private changeState(newState: StatefulButtonState): void {
|
||||
this.clearDismissTimer();
|
||||
this.state.set(newState);
|
||||
}
|
||||
|
||||
private clearDismissTimer(): void {
|
||||
if (this.dismissTimer) {
|
||||
clearTimeout(this.dismissTimer);
|
||||
this.dismissTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
#makeWidthAnimation(width: string): void {
|
||||
const animation = this.#animationBuilder.build([
|
||||
style({ width: '*' }),
|
||||
animate('250ms', style({ width })),
|
||||
]);
|
||||
|
||||
const player = animation.create(this.buttonElement().nativeElement);
|
||||
player.play();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user