Merged PR 1881: Stateful Remi Button

#5203

Related work items: #5203
This commit is contained in:
Lorenz Hilpert
2025-07-14 11:57:03 +00:00
committed by Nino Righi
parent 5f74c6ddf8
commit 40c9d51dfc
24 changed files with 1418 additions and 604 deletions

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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.

View File

@@ -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',
],
};

View File

@@ -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"
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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,
},
);
}
}

View File

@@ -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>

View File

@@ -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 : '';
}
}

View File

@@ -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()"
/>

View File

@@ -0,0 +1 @@
// Component now uses ui-stateful-button which handles all styling

View File

@@ -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;
}
}
}

View 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');
});
});

View 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');
});
});

View File

@@ -2,3 +2,4 @@
@use "lib/icon-button";
@use "lib/info-button";
@use "lib/text-button";
@use "lib/stateful-button/stateful-button";

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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();
}
}