From 40c9d51dfcaaf14a5979720e30a74abedabea10c Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Mon, 14 Jul 2025 11:57:03 +0000 Subject: [PATCH] Merged PR 1881: Stateful Remi Button #5203 Related work items: #5203 --- .github/instructions/nx.instructions.md | 16 +- .../src/lib/in-flight.decorator.spec.ts | 27 +- .../decorators/src/lib/in-flight.decorator.ts | 38 +- .../calc-available-stock.helper.spec.ts | 2 +- .../calc-stock-to-remit.helper.spec.ts | 2 +- .../helpers/calc-target-stock.helper.spec.ts | 2 +- .../feature/remission-list/README.md | 14 +- .../feature/remission-list/jest.config.ts | 44 +- .../feature/remission-list/project.json | 40 +- .../remission-list-item.component.ts | 428 ++++++++--------- .../remission-list-select.component.ts | 96 ++-- .../src/lib/remission-list.component.html | 72 +-- .../src/lib/remission-list.component.ts | 438 +++++++++--------- .../remit-button/remit-button.component.html | 16 + .../remit-button/remit-button.component.scss | 1 + .../remit-button/remit-button.component.ts | 58 +++ .../lib/bullet-list-item.component.spec.ts | 162 +++++++ .../src/lib/bullet-list.component.spec.ts | 85 ++++ libs/ui/buttons/src/buttons.scss | 1 + libs/ui/buttons/src/index.ts | 1 + .../lib/stateful-button/_stateful-button.scss | 24 + .../stateful-button.component.html | 52 +++ .../stateful-button.component.spec.ts | 227 +++++++++ .../stateful-button.component.ts | 176 +++++++ 24 files changed, 1418 insertions(+), 604 deletions(-) create mode 100644 libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.html create mode 100644 libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.scss create mode 100644 libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.ts create mode 100644 libs/ui/bullet-list/src/lib/bullet-list-item.component.spec.ts create mode 100644 libs/ui/bullet-list/src/lib/bullet-list.component.spec.ts create mode 100644 libs/ui/buttons/src/lib/stateful-button/_stateful-button.scss create mode 100644 libs/ui/buttons/src/lib/stateful-button/stateful-button.component.html create mode 100644 libs/ui/buttons/src/lib/stateful-button/stateful-button.component.spec.ts create mode 100644 libs/ui/buttons/src/lib/stateful-button/stateful-button.component.ts diff --git a/.github/instructions/nx.instructions.md b/.github/instructions/nx.instructions.md index 27ac23e40..263761fd8 100644 --- a/.github/instructions/nx.instructions.md +++ b/.github/instructions/nx.instructions.md @@ -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 ` 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 + + diff --git a/libs/common/decorators/src/lib/in-flight.decorator.spec.ts b/libs/common/decorators/src/lib/in-flight.decorator.spec.ts index f71d0dea0..9b0aad85f 100644 --- a/libs/common/decorators/src/lib/in-flight.decorator.spec.ts +++ b/libs/common/decorators/src/lib/in-flight.decorator.spec.ts @@ -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); }); }); diff --git a/libs/common/decorators/src/lib/in-flight.decorator.ts b/libs/common/decorators/src/lib/in-flight.decorator.ts index e5ae18302..5ea7e4421 100644 --- a/libs/common/decorators/src/lib/in-flight.decorator.ts +++ b/libs/common/decorators/src/lib/in-flight.decorator.ts @@ -20,8 +20,8 @@ export function InFlight< const inFlightMap = new WeakMap>(); 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 Promise>( const inFlightMap = new WeakMap>>(); 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 Promise>( if (!inFlightMap.has(this)) { inFlightMap.set(this, new Map()); } - const instanceMap = inFlightMap.get(this)!; + const instanceMap = inFlightMap.get(this) as Map>; // Generate cache key const key = options.keyGenerator @@ -122,15 +116,9 @@ export function InFlightWithKey Promise>( // 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 Promise>( >(); 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 Promise>( 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>; + const instanceCache = cacheMap.get(this) as Map; // Generate cache key const key = options.keyGenerator diff --git a/libs/remission/data-access/src/lib/helpers/calc-available-stock.helper.spec.ts b/libs/remission/data-access/src/lib/helpers/calc-available-stock.helper.spec.ts index aa391699c..0a8de3ae4 100644 --- a/libs/remission/data-access/src/lib/helpers/calc-available-stock.helper.spec.ts +++ b/libs/remission/data-access/src/lib/helpers/calc-available-stock.helper.spec.ts @@ -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', () => { diff --git a/libs/remission/data-access/src/lib/helpers/calc-stock-to-remit.helper.spec.ts b/libs/remission/data-access/src/lib/helpers/calc-stock-to-remit.helper.spec.ts index 2fb40c990..cbee42252 100644 --- a/libs/remission/data-access/src/lib/helpers/calc-stock-to-remit.helper.spec.ts +++ b/libs/remission/data-access/src/lib/helpers/calc-stock-to-remit.helper.spec.ts @@ -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', () => { diff --git a/libs/remission/data-access/src/lib/helpers/calc-target-stock.helper.spec.ts b/libs/remission/data-access/src/lib/helpers/calc-target-stock.helper.spec.ts index fcd9e6a96..b3645c9d2 100644 --- a/libs/remission/data-access/src/lib/helpers/calc-target-stock.helper.spec.ts +++ b/libs/remission/data-access/src/lib/helpers/calc-target-stock.helper.spec.ts @@ -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', () => { diff --git a/libs/remission/feature/remission-list/README.md b/libs/remission/feature/remission-list/README.md index 7681e3423..182acd0ed 100644 --- a/libs/remission/feature/remission-list/README.md +++ b/libs/remission/feature/remission-list/README.md @@ -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. diff --git a/libs/remission/feature/remission-list/jest.config.ts b/libs/remission/feature/remission-list/jest.config.ts index aff98f974..b86edf16c 100644 --- a/libs/remission/feature/remission-list/jest.config.ts +++ b/libs/remission/feature/remission-list/jest.config.ts @@ -1,22 +1,22 @@ -export default { - displayName: 'remission-feature-remission-list', - preset: '../../../../jest.preset.js', - setupFilesAfterEnv: ['/src/test-setup.ts'], - coverageDirectory: - '../../../../coverage/libs/remission/feature/remission-list', - transform: { - '^.+\\.(ts|mjs|js|html)$': [ - 'jest-preset-angular', - { - tsconfig: '/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: ['/src/test-setup.ts'], + coverageDirectory: + '../../../../coverage/libs/remission/feature/remission-list', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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', + ], +}; diff --git a/libs/remission/feature/remission-list/project.json b/libs/remission/feature/remission-list/project.json index 0b57acf2c..d0100292f 100644 --- a/libs/remission/feature/remission-list/project.json +++ b/libs/remission/feature/remission-list/project.json @@ -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" + } + } +} diff --git a/libs/remission/feature/remission-list/src/lib/remission-list-item/remission-list-item.component.ts b/libs/remission/feature/remission-list/src/lib/remission-list-item/remission-list-item.component.ts index bad1eb6a8..4aee8e2f0 100644 --- a/libs/remission/feature/remission-list/src/lib/remission-list-item/remission-list-item.component.ts +++ b/libs/remission/feature/remission-list/src/lib/remission-list-item/remission-list-item.component.ts @@ -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(); - - /** - * Stock information for the item. - */ - stock = input.required(); - - /** - * Optional product group value for display or filtering. - */ - productGroupValue = input(''); - - /** - * 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 - */ - async openRemissionQuantityDialog(): Promise { - 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(); + + /** + * Stock information for the item. + */ + stock = input.required(); + + /** + * Optional product group value for display or filtering. + */ + productGroupValue = input(''); + + /** + * 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 + */ + async openRemissionQuantityDialog(): Promise { + 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); + } + } +} diff --git a/libs/remission/feature/remission-list/src/lib/remission-list-select/remission-list-select.component.ts b/libs/remission/feature/remission-list/src/lib/remission-list-select/remission-list-select.component.ts index 26a830ed1..d8ffd332f 100644 --- a/libs/remission/feature/remission-list/src/lib/remission-list-select/remission-list-select.component.ts +++ b/libs/remission/feature/remission-list/src/lib/remission-list-select/remission-list-select.component.ts @@ -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, + }, + ); + } +} diff --git a/libs/remission/feature/remission-list/src/lib/remission-list.component.html b/libs/remission/feature/remission-list/src/lib/remission-list.component.html index e038dbaac..c31f516db 100644 --- a/libs/remission/feature/remission-list/src/lib/remission-list.component.html +++ b/libs/remission/feature/remission-list/src/lib/remission-list.component.html @@ -1,36 +1,36 @@ - - - - - - - - {{ hits() }} Einträge - - -
- @for (item of items(); track item.id) { - @defer (on viewport) { - - - - } @placeholder { -
- -
- } - } -
+ + + + + + + + {{ hits() }} Einträge + + +
+ @for (item of items(); track item.id) { + @defer (on viewport) { + + + + } @placeholder { +
+ +
+ } + } +
diff --git a/libs/remission/feature/remission-list/src/lib/remission-list.component.ts b/libs/remission/feature/remission-list/src/lib/remission-list.component.ts index 4b8cdbd7c..11daacb62 100644 --- a/libs/remission/feature/remission-list/src/lib/remission-list.component.ts +++ b/libs/remission/feature/remission-list/src/lib/remission-list.component.ts @@ -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 : ''; + } +} diff --git a/libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.html b/libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.html new file mode 100644 index 000000000..8c12e7d00 --- /dev/null +++ b/libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.html @@ -0,0 +1,16 @@ + diff --git a/libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.scss b/libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.scss new file mode 100644 index 000000000..cea1ec471 --- /dev/null +++ b/libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.scss @@ -0,0 +1 @@ +// Component now uses ui-stateful-button which handles all styling diff --git a/libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.ts b/libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.ts new file mode 100644 index 000000000..3843722da --- /dev/null +++ b/libs/remission/feature/remission-list/src/lib/remit-button/remit-button.component.ts @@ -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('default'); + isLoading = signal(false); + + private timer: ReturnType | 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; + } + } +} diff --git a/libs/ui/bullet-list/src/lib/bullet-list-item.component.spec.ts b/libs/ui/bullet-list/src/lib/bullet-list-item.component.spec.ts new file mode 100644 index 000000000..ce22c3983 --- /dev/null +++ b/libs/ui/bullet-list/src/lib/bullet-list-item.component.spec.ts @@ -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: '', + ownIcon: '', + parentIcon: '', + childIcon: '', + newParentIcon: '', +}; + +// Test host component without parent BulletListComponent +@Component({ + template: ` + + Test item content + + `, + standalone: true, + imports: [BulletListItemComponent], + providers: [provideIcons(testIcons)], +}) +class TestHostComponent { + testIcon: string | undefined = undefined; +} + +// Test host component with parent BulletListComponent +@Component({ + template: ` + + + Test item content + + + `, + standalone: true, + imports: [BulletListComponent, BulletListItemComponent], +}) +class TestHostWithParentComponent { + parentIcon = 'parentIcon'; + childIcon: string | undefined = undefined; +} + +describe('BulletListItemComponent', () => { + let component: BulletListItemComponent; + let fixture: ComponentFixture; + + 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'); + }); +}); \ No newline at end of file diff --git a/libs/ui/bullet-list/src/lib/bullet-list.component.spec.ts b/libs/ui/bullet-list/src/lib/bullet-list.component.spec.ts new file mode 100644 index 000000000..2617e079d --- /dev/null +++ b/libs/ui/bullet-list/src/lib/bullet-list.component.spec.ts @@ -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: '', + customTestIcon: '', +}; + +// Test host component to verify content projection +@Component({ + template: ` + +
Test content
+
+ `, + standalone: true, + imports: [BulletListComponent], +}) +class TestHostComponent { + testIcon = 'testIcon'; +} + +describe('BulletListComponent', () => { + let component: BulletListComponent; + let fixture: ComponentFixture; + + 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'); + }); +}); \ No newline at end of file diff --git a/libs/ui/buttons/src/buttons.scss b/libs/ui/buttons/src/buttons.scss index d8a5f0d6e..1a8ca743c 100644 --- a/libs/ui/buttons/src/buttons.scss +++ b/libs/ui/buttons/src/buttons.scss @@ -2,3 +2,4 @@ @use "lib/icon-button"; @use "lib/info-button"; @use "lib/text-button"; +@use "lib/stateful-button/stateful-button"; diff --git a/libs/ui/buttons/src/index.ts b/libs/ui/buttons/src/index.ts index 6b84570f2..7633cdb7d 100644 --- a/libs/ui/buttons/src/index.ts +++ b/libs/ui/buttons/src/index.ts @@ -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'; diff --git a/libs/ui/buttons/src/lib/stateful-button/_stateful-button.scss b/libs/ui/buttons/src/lib/stateful-button/_stateful-button.scss new file mode 100644 index 000000000..4261ee908 --- /dev/null +++ b/libs/ui/buttons/src/lib/stateful-button/_stateful-button.scss @@ -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; +} diff --git a/libs/ui/buttons/src/lib/stateful-button/stateful-button.component.html b/libs/ui/buttons/src/lib/stateful-button/stateful-button.component.html new file mode 100644 index 000000000..20babf022 --- /dev/null +++ b/libs/ui/buttons/src/lib/stateful-button/stateful-button.component.html @@ -0,0 +1,52 @@ + + @switch (state()) { + @case ('default') { +
+ {{ defaultContent() }} +
+ } + @case ('success') { +
+ + {{ successContent() }} +
+ } + @case ('error') { +
+ {{ errorContent() }} + @if (errorAction()) { + + {{ errorAction() }} + + } @else { + + } +
+ } + } +
diff --git a/libs/ui/buttons/src/lib/stateful-button/stateful-button.component.spec.ts b/libs/ui/buttons/src/lib/stateful-button/stateful-button.component.spec.ts new file mode 100644 index 000000000..a7bf26325 --- /dev/null +++ b/libs/ui/buttons/src/lib/stateful-button/stateful-button.component.spec.ts @@ -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; + 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'); + }); +}); \ No newline at end of file diff --git a/libs/ui/buttons/src/lib/stateful-button/stateful-button.component.ts b/libs/ui/buttons/src/lib/stateful-button/stateful-button.component.ts new file mode 100644 index 000000000..bfefa27a3 --- /dev/null +++ b/libs/ui/buttons/src/lib/stateful-button/stateful-button.component.ts @@ -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('default'); + + stateEffect = effect(() => { + this.state(); + + untracked(() => { + this.#makeWidthAnimation(this.widthStyle()); + }); + }); + + // Content inputs for each state + defaultContent = input.required(); + successContent = input.required(); + errorContent = input.required(); + errorAction = input(); + + // Width configuration for each state + defaultWidth = input('100%'); + successWidth = input('100%'); + errorWidth = input('100%'); + + // Optional dismiss timeout in milliseconds + dismiss = input(); + + // Button properties + color = input('primary'); + size = input('medium'); + pending = input(false); + + // Output events + clicked = output(); + action = output(); + + // Internal state + private dismissTimer: ReturnType | 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(); + } +}