From 3eb6981e3a1e16705c310438a664b5fabc6eba4f Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Fri, 6 Jun 2025 15:34:33 +0000 Subject: [PATCH] Merged PR 1851: Retoure // Mehrere Belege in der Retouren-Detailansicht anzeigen Related work items: #5002, #5148 --- .github/instructions/nx.instructions.md | 2 +- .vscode/settings.json | 3 + .../expandable-directives.stories.ts | 49 ++ libs/common/data-access/src/index.ts | 1 + .../data-access/src/lib/operators/index.ts | 1 + .../src/lib/operators/take-until-aborted.ts | 50 ++ .../print-dialog.component.spec.ts | 290 ----------- libs/oms/data-access/src/index.ts | 2 + .../create-return-process.error.test.ts | 8 +- .../create-return-process.error.ts | 3 +- .../can-return-receipt-item.helper.spec.ts | 73 +++ .../can-return-receipt-item.helper.ts | 25 + .../get-receipt-item-action.helper.spec.ts | 55 +++ .../get-receipt-item-action.helper.ts | 24 + ...ceipt-item-product-category.helper.spec.ts | 55 +++ ...et-receipt-item-product-category.helper.ts | 23 + .../src/lib/helpers/return-process/index.ts | 12 +- .../receipt-item-has-category.helper.spec.ts | 62 +++ .../receipt-item-has-category.helper.ts | 24 + libs/oms/data-access/src/lib/models/index.ts | 2 + .../src/lib/models/receipt-item-list-item.ts | 10 + .../src/lib/models/receipt-item.ts | 1 + .../src/lib/models/receipt-list-item.ts | 1 + .../oms/data-access/src/lib/models/receipt.ts | 2 + .../src/lib/models/shipping-address-2.ts | 3 + .../data-access/src/lib/operators/index.ts | 1 + .../src/lib/operators/take-until-aborted.ts | 50 ++ .../src/lib/questions/constants.ts | 1 + .../data-access/src/lib/questions/registry.ts | 1 + .../lib/services/return-can-return.service.ts | 30 +- .../services/return-details.service.spec.ts | 97 +++- .../lib/services/return-details.service.ts | 137 ++++-- .../lib/stores/return-details.store.spec.ts | 186 ++----- .../src/lib/stores/return-details.store.ts | 457 ++++++++++-------- .../lib/stores/return-process.store.spec.ts | 124 ++++- .../src/lib/stores/return-process.store.ts | 54 ++- .../lib/stores/return-search.store.spec.ts | 4 +- .../return-details-data.component.html | 4 +- .../return-details-data.component.ts | 5 +- .../return-details-header.component.ts | 16 +- .../return-details-lazy.component.html | 43 ++ .../return-details-lazy.component.scss | 0 .../return-details-lazy.component.ts | 57 +++ ...rn-details-order-group-data.component.html | 90 ++-- ...turn-details-order-group-data.component.ts | 32 +- ...s-order-group-item-controls.component.html | 15 +- ...ils-order-group-item-controls.component.ts | 136 ++---- ...rn-details-order-group-item.component.html | 9 +- ...details-order-group-item.component.spec.ts | 110 ----- ...turn-details-order-group-item.component.ts | 48 +- .../return-details-order-group.component.html | 15 +- .../return-details-order-group.component.ts | 75 ++- .../return-details-static.component.html | 42 ++ .../return-details-static.component.scss | 0 .../return-details-static.component.ts | 33 ++ .../src/lib/return-details.component.html | 80 +-- .../src/lib/return-details.component.ts | 201 ++++---- libs/ui/expandable/README.md | 180 +++++++ libs/ui/expandable/eslint.config.mjs | 34 ++ libs/ui/expandable/jest.config.ts | 21 + libs/ui/expandable/project.json | 20 + libs/ui/expandable/src/index.ts | 5 + .../expandable/src/lib/collapsed.directive.ts | 59 +++ libs/ui/expandable/src/lib/directives.ts | 22 + .../src/lib/expandable-trigger.directive.ts | 52 ++ .../src/lib/expandable.directive.ts | 40 ++ .../expandable/src/lib/expanded.directive.ts | 59 +++ libs/ui/expandable/src/test-setup.ts | 6 + libs/ui/expandable/tsconfig.json | 28 ++ libs/ui/expandable/tsconfig.lib.json | 17 + libs/ui/expandable/tsconfig.spec.json | 16 + tsconfig.base.json | 1 + 72 files changed, 2238 insertions(+), 1256 deletions(-) create mode 100644 apps/isa-app/stories/ui/expandable/expandable-directives.stories.ts create mode 100644 libs/common/data-access/src/lib/operators/index.ts create mode 100644 libs/common/data-access/src/lib/operators/take-until-aborted.ts delete mode 100644 libs/common/print/src/lib/print-dialog/print-dialog.component.spec.ts create mode 100644 libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.spec.ts create mode 100644 libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.ts create mode 100644 libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.spec.ts create mode 100644 libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.ts create mode 100644 libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.spec.ts create mode 100644 libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.ts create mode 100644 libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.spec.ts create mode 100644 libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.ts create mode 100644 libs/oms/data-access/src/lib/models/receipt-item-list-item.ts create mode 100644 libs/oms/data-access/src/lib/models/shipping-address-2.ts create mode 100644 libs/oms/data-access/src/lib/operators/index.ts create mode 100644 libs/oms/data-access/src/lib/operators/take-until-aborted.ts create mode 100644 libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.html create mode 100644 libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.scss create mode 100644 libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.ts delete mode 100644 libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.spec.ts create mode 100644 libs/oms/feature/return-details/src/lib/return-details-static/return-details-static.component.html create mode 100644 libs/oms/feature/return-details/src/lib/return-details-static/return-details-static.component.scss create mode 100644 libs/oms/feature/return-details/src/lib/return-details-static/return-details-static.component.ts create mode 100644 libs/ui/expandable/README.md create mode 100644 libs/ui/expandable/eslint.config.mjs create mode 100644 libs/ui/expandable/jest.config.ts create mode 100644 libs/ui/expandable/project.json create mode 100644 libs/ui/expandable/src/index.ts create mode 100644 libs/ui/expandable/src/lib/collapsed.directive.ts create mode 100644 libs/ui/expandable/src/lib/directives.ts create mode 100644 libs/ui/expandable/src/lib/expandable-trigger.directive.ts create mode 100644 libs/ui/expandable/src/lib/expandable.directive.ts create mode 100644 libs/ui/expandable/src/lib/expanded.directive.ts create mode 100644 libs/ui/expandable/src/test-setup.ts create mode 100644 libs/ui/expandable/tsconfig.json create mode 100644 libs/ui/expandable/tsconfig.lib.json create mode 100644 libs/ui/expandable/tsconfig.spec.json diff --git a/.github/instructions/nx.instructions.md b/.github/instructions/nx.instructions.md index 4af8bc88b..60e93b672 100644 --- a/.github/instructions/nx.instructions.md +++ b/.github/instructions/nx.instructions.md @@ -27,6 +27,6 @@ If the user wants to generate something, use the following flow: - wait for the user to finish the generator - read the generator log file using the 'nx_read_generator_log' tool - use the information provided in the log file to answer the user's question or continue with what they were doing -undefined + diff --git a/.vscode/settings.json b/.vscode/settings.json index 2b63b6d10..aab4c8d4e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,6 +30,9 @@ } ], "github.copilot.chat.codeGeneration.instructions": [ + { + "file": ".vscode/llms/angular.txt" + }, { "file": "docs/tech-stack.md" }, diff --git a/apps/isa-app/stories/ui/expandable/expandable-directives.stories.ts b/apps/isa-app/stories/ui/expandable/expandable-directives.stories.ts new file mode 100644 index 000000000..b478f363f --- /dev/null +++ b/apps/isa-app/stories/ui/expandable/expandable-directives.stories.ts @@ -0,0 +1,49 @@ +import { Component, Input } from '@angular/core'; +import { ButtonComponent } from '@isa/ui/buttons'; +import { ExpandableDirectives } from '@isa/ui/expandable'; +import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular'; + +@Component({ + selector: 'app-expandable-directives', + template: ` +
+ +
Expanded Content
+
Collapsed Content
+
+ `, + standalone: true, + imports: [ExpandableDirectives, ButtonComponent], +}) +class ExpandableDirectivesComponent { + @Input() + isExpanded = false; +} + +const meta: Meta = { + title: 'ui/expandable/Expandable', + component: ExpandableDirectivesComponent, + argTypes: { + isExpanded: { + control: 'boolean', + description: 'Controls the expanded state of the section', + table: { + defaultValue: { summary: 'false' }, + }, + }, + }, + args: { + isExpanded: false, + }, + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/libs/common/data-access/src/index.ts b/libs/common/data-access/src/index.ts index 0fb10966f..b2d994353 100644 --- a/libs/common/data-access/src/index.ts +++ b/libs/common/data-access/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/errors'; export * from './lib/models'; +export * from './lib/operators'; diff --git a/libs/common/data-access/src/lib/operators/index.ts b/libs/common/data-access/src/lib/operators/index.ts new file mode 100644 index 000000000..5c6ce6584 --- /dev/null +++ b/libs/common/data-access/src/lib/operators/index.ts @@ -0,0 +1 @@ +export * from './take-until-aborted'; diff --git a/libs/common/data-access/src/lib/operators/take-until-aborted.ts b/libs/common/data-access/src/lib/operators/take-until-aborted.ts new file mode 100644 index 000000000..a2461f805 --- /dev/null +++ b/libs/common/data-access/src/lib/operators/take-until-aborted.ts @@ -0,0 +1,50 @@ +import { Observable, fromEvent } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +/** + * Creates an Observable that emits when an AbortSignal is aborted. + * + * @param signal - The AbortSignal instance to listen to + * @returns An Observable that emits and completes when the signal is aborted + */ +export const fromAbortSignal = (signal: AbortSignal): Observable => { + // If the signal is already aborted, return an Observable that immediately completes + if (signal.aborted) { + return new Observable((subscriber) => { + subscriber.complete(); + }); + } + + // Otherwise, create an Observable from the abort event + return new Observable((subscriber) => { + const abortHandler = () => { + subscriber.next(); + subscriber.complete(); + }; + + // Listen for the 'abort' event + signal.addEventListener('abort', abortHandler); + + // Clean up the event listener when the Observable is unsubscribed + return () => { + signal.removeEventListener('abort', abortHandler); + }; + }); +}; + +/** + * Operator that completes the source Observable when the provided AbortSignal is aborted. + * Similar to takeUntil, but works with AbortSignal instead of an Observable. + * + * @param signal - The AbortSignal instance that will trigger completion when aborted + * @returns An Observable that completes when the source completes or when the signal is aborted + */ +export const takeUntilAborted = + (signal: AbortSignal) => + (source: Observable): Observable => { + // Convert the AbortSignal to an Observable + const aborted$ = fromAbortSignal(signal); + + // Use the standard takeUntil operator with our abort Observable + return source.pipe(takeUntil(aborted$)); + }; diff --git a/libs/common/print/src/lib/print-dialog/print-dialog.component.spec.ts b/libs/common/print/src/lib/print-dialog/print-dialog.component.spec.ts deleted file mode 100644 index b2431f9f3..000000000 --- a/libs/common/print/src/lib/print-dialog/print-dialog.component.spec.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; -import { - PrintDialogComponent, - PrinterDialogData, -} from './print-dialog.component'; -import { ButtonComponent } from '@isa/ui/buttons'; -import { ListboxDirective, ListboxItemDirective } from '@isa/ui/input-controls'; -import { MockComponent, MockDirective } from 'ng-mocks'; -import { Printer } from '../models'; -import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; - -describe('PrintDialogComponent', () => { - let spectator: Spectator; - let component: PrintDialogComponent; - - // Mock printers for testing - const mockPrinters: Printer[] = [ - { - key: 'printer1', - value: 'Printer 1', - selected: false, - enabled: true, - description: 'First test printer', - }, - { - key: 'printer2', - value: 'Printer 2', - selected: true, - enabled: true, - description: 'Second test printer', - }, - { - key: 'printer3', - value: 'Printer 3', - selected: false, - enabled: false, - description: 'Disabled test printer', - }, - ]; - - // Mock print function - const mockPrintFn = jest.fn().mockResolvedValue(undefined); - - // Default dialog data - const defaultData: PrinterDialogData = { - printers: mockPrinters, - print: mockPrintFn, - }; - - // Mock DialogRef - const mockDialogRef = { - close: jest.fn(), - }; - - const createComponent = createComponentFactory({ - component: PrintDialogComponent, - declarations: [ - MockComponent(ButtonComponent), - MockDirective(ListboxDirective), - MockDirective(ListboxItemDirective), - ], - providers: [ - { provide: DialogRef, useValue: mockDialogRef }, - { provide: DIALOG_DATA, useValue: defaultData }, - ], - detectChanges: false, - }); - - beforeEach(() => { - // Reset mocks - mockPrintFn.mockClear(); - mockDialogRef.close.mockClear(); - - // Create component without providing data prop since we provide it via DIALOG_DATA - spectator = createComponent(); - - component = spectator.component; - spectator.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with the selected printer', () => { - // Assert - expect(component.printer()).toEqual(mockPrinters[1]); // The one with selected: true - }); - - it('should compute selected array correctly with a printer', () => { - // Arrange - const selectedPrinter = mockPrinters[0]; - component.printer.set(selectedPrinter); - - // Act - const result = component.selected(); - - // Assert - expect(result).toEqual([selectedPrinter]); - }); - - it('should compute selected array as empty when no printer is selected', () => { - // Arrange - component.printer.set(undefined); - - // Act - const result = component.selected(); - - // Assert - expect(result).toEqual([]); - }); - - it('should compute canPrint as true when printer is selected and not printing', () => { - // Arrange - component.printer.set(mockPrinters[0]); - component.printing.set(false); - - // Act - const result = component.canPrint(); - - // Assert - expect(result).toBe(true); - }); - - it('should compute canPrint as false when no printer is selected', () => { - // Arrange - component.printer.set(undefined); - component.printing.set(false); - - // Act - const result = component.canPrint(); - - // Assert - expect(result).toBe(false); - }); - - it('should compute canPrint as false when printing is in progress', () => { - // Arrange - component.printer.set(mockPrinters[0]); - component.printing.set(true); - - // Act - const result = component.canPrint(); - - // Assert - expect(result).toBe(false); - }); - - it('should compare printers by key', () => { - // Arrange - const printer1 = { ...mockPrinters[0] }; - const printer2 = { ...mockPrinters[0] }; // Same key as printer1 - const printer3 = { ...mockPrinters[1] }; // Different key - - // Act & Assert - expect(component.compareWith(printer1, printer2)).toBe(true); - expect(component.compareWith(printer1, printer3)).toBe(false); - }); - - it('should select a printer and clear error', () => { - // Arrange - const initialError = new Error('Test error'); - component.error.set(initialError); - const printer = mockPrinters[0]; - - // Act - component.select(printer); - - // Assert - expect(component.printer()).toEqual(printer); - expect(component.error()).toBeUndefined(); - }); - - it('should not print when canPrint is false', async () => { - // Arrange - component.printer.set(undefined); // Makes canPrint() false - const closeSpy = jest.spyOn(component, 'close'); - - // Act - await component.print(); - - // Assert - expect(mockPrintFn).not.toHaveBeenCalled(); - expect(closeSpy).not.toHaveBeenCalled(); - expect(component.printing()).toBe(false); - }); - it('should print and close dialog when print succeeds', async () => { - // Arrange - const selectedPrinter = mockPrinters[0]; - component.printer.set(selectedPrinter); - const closeSpy = jest.spyOn(component, 'close'); - - // Act - await component.print(); - - // Assert - // The printing flag stays true when success happens and dialog is closed - expect(component.printing()).toBe(true); - expect(mockPrintFn).toHaveBeenCalledWith(selectedPrinter); - expect(closeSpy).toHaveBeenCalledWith({ printer: selectedPrinter }); - }); - - it('should handle print errors', async () => { - // Arrange - const selectedPrinter = mockPrinters[0]; - component.printer.set(selectedPrinter); - const testError = new Error('Print failed'); - mockPrintFn.mockRejectedValueOnce(testError); - const closeSpy = jest.spyOn(component, 'close'); - - // Act - await component.print(); - - // Assert - expect(component.printing()).toBe(false); // Reset to false after error - expect(mockPrintFn).toHaveBeenCalledWith(selectedPrinter); - expect(closeSpy).not.toHaveBeenCalled(); - expect(component.error()).toBe(testError); - }); - - it('should format Error objects correctly', () => { - // Arrange - const errorMessage = 'Test error message'; - const testError = new Error(errorMessage); - - // Act - const result = component.formatError(testError); - - // Assert - expect(result).toBe(errorMessage); - }); - - it('should format string errors correctly', () => { - // Arrange - const errorMessage = 'Test error message'; - - // Act - const result = component.formatError(errorMessage); - - // Assert - expect(result).toBe(errorMessage); - }); - - it('should format unknown errors correctly', () => { - // Arrange - const unknownError = { something: 'wrong' }; - - // Act - const result = component.formatError(unknownError); - - // Assert - expect(result).toBe('Unbekannter Fehler'); - }); - - it('should show error message in template when error exists', () => { - // Arrange - const errorMessage = 'Display this error'; - component.error.set(errorMessage); - spectator.detectChanges(); - - // Act - const errorElement = spectator.query('.text-isa-accent-red'); - - // Assert - expect(errorElement).toHaveText(errorMessage); - }); - - it('should display printers in the listbox', () => { - // Arrange & Act - spectator.detectChanges(); - const listboxItems = spectator.queryAll('button[uiListboxItem]'); - - // Assert - expect(listboxItems.length).toBe(mockPrinters.length); - expect(listboxItems[0]).toHaveText(mockPrinters[0].value); - expect(listboxItems[1]).toHaveText(mockPrinters[1].value); - expect(listboxItems[2]).toHaveText(mockPrinters[2].value); - }); - - it('should call close with undefined printer when cancel button is clicked', () => { - // Arrange - const closeSpy = jest.spyOn(component, 'close'); - - // Act - spectator.click('button[color="secondary"]'); - - // Assert - expect(closeSpy).toHaveBeenCalledWith({ printer: undefined }); - }); -}); diff --git a/libs/oms/data-access/src/index.ts b/libs/oms/data-access/src/index.ts index ab880af68..b249bfe1b 100644 --- a/libs/oms/data-access/src/index.ts +++ b/libs/oms/data-access/src/index.ts @@ -11,6 +11,7 @@ * - Schemas: Validation schemas for ensuring data integrity * - Return Process: Question flows and validation for return processing * - Error handling: Specialized error types for OMS operations + * - Operators: Custom RxJS operators for OMS-specific use cases */ export * from './lib/errors'; @@ -19,4 +20,5 @@ export * from './lib/models'; export * from './lib/helpers/return-process'; export * from './lib/schemas'; export * from './lib/services'; +export * from './lib/operators'; export * from './lib/stores'; diff --git a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts index 03b580e88..4d3cdab72 100644 --- a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts +++ b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts @@ -9,8 +9,12 @@ import { describe('CreateReturnProcessError', () => { const params = { processId: 123, - receipt: { id: 321 } as Receipt, - items: [] as ReceiptItem[], + returns: [ + { + receipt: { id: 321 } as Receipt, + items: [] as ReceiptItem[], + }, + ], }; it('should create an error instance with NO_RETURNABLE_ITEMS reason', () => { diff --git a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts index bf9317c16..54f4414da 100644 --- a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts +++ b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts @@ -78,8 +78,7 @@ export class CreateReturnProcessError extends DataAccessError<'CREATE_RETURN_PRO public readonly reason: CreateReturnProcessErrorReason, public readonly params: { processId: number; - receipt: Receipt; - items: ReceiptItem[]; + returns: { receipt: Receipt; items: ReceiptItem[] }[]; }, ) { super('CREATE_RETURN_PROCESS', CreateReturnProcessErrorMessages[reason]); diff --git a/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.spec.ts b/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.spec.ts new file mode 100644 index 000000000..1c890ff03 --- /dev/null +++ b/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.spec.ts @@ -0,0 +1,73 @@ +import { canReturnReceiptItem } from './can-return-receipt-item.helper'; +import { ReceiptItem } from '../../models/receipt-item'; +import { Product } from '../../models/product'; +import { Quantity } from '../../models/quantity'; + +describe('canReturnReceiptItem', () => { + const product: Product = { + name: 'Test Product', + contributors: 'Author', + catalogProductNumber: '123', + ean: '1234567890123', + format: 'Hardcover', + formatDetail: 'Detail', + volume: '1', + manufacturer: 'Test Publisher', + }; + const quantity: Quantity = { quantity: 1 }; + + const baseItem: ReceiptItem = { + id: 1, + product, + quantity, + receiptNumber: 'R-001', + actions: [], + }; + + it('should return false if actions property is missing', () => { + const item = { ...baseItem }; + delete (item as { actions?: unknown }).actions; + expect(canReturnReceiptItem(item as ReceiptItem)).toBe(false); + }); + + it('should return false if canReturn action is missing', () => { + const item = { ...baseItem, actions: [{ key: 'other', value: 'true' }] }; + expect(canReturnReceiptItem(item)).toBe(false); + }); + + it('should return false if canReturn action value is falsy', () => { + const item = { ...baseItem, actions: [{ key: 'canReturn', value: '' }] }; + // coerceBooleanProperty('') returns true, so this should be true + expect(canReturnReceiptItem(item)).toBe(true); + const itemZero = { + ...baseItem, + actions: [{ key: 'canReturn', value: '0' }], + }; + // coerceBooleanProperty('0') returns true (string '0' is truthy) + expect(canReturnReceiptItem(itemZero)).toBe(true); + }); + + it('should return true if canReturn action value is truthy', () => { + const item = { + ...baseItem, + actions: [{ key: 'canReturn', value: 'true' }], + }; + expect(canReturnReceiptItem(item)).toBe(true); + }); + + it('should coerce canReturn action value to boolean', () => { + const item = { ...baseItem, actions: [{ key: 'canReturn', value: '1' }] }; + expect(canReturnReceiptItem(item)).toBe(true); + const itemFalse = { + ...baseItem, + actions: [{ key: 'canReturn', value: '' }], + }; + expect(canReturnReceiptItem(itemFalse)).toBe(true); + const itemStringZero = { + ...baseItem, + actions: [{ key: 'canReturn', value: '0' }], + }; + // coerceBooleanProperty('0') returns true + expect(canReturnReceiptItem(itemStringZero)).toBe(true); + }); +}); diff --git a/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.ts b/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.ts new file mode 100644 index 000000000..81f27e6ff --- /dev/null +++ b/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.ts @@ -0,0 +1,25 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { ReceiptItem } from '../../models'; +import { getReceiptItemAction } from './get-receipt-item-action.helper'; + +/** + * Determines if a receipt item can be returned. + * + * @param receiptItem - The receipt item to check for return eligibility. Must have an 'actions' property (array of action objects). + * @returns {boolean} True if the item has a 'canReturn' action with a truthy value (coerced to boolean), otherwise false. + * + * @remarks + * - Returns false if the 'actions' property is missing or not an array. + * - Returns false if the 'canReturn' action is not present in the actions array. + * - Uses Angular's coerceBooleanProperty to interpret the action value. + */ +export function canReturnReceiptItem(receiptItem: ReceiptItem): boolean { + if (!receiptItem.actions) { + return false; + } + const canReturnAction = getReceiptItemAction(receiptItem, 'canReturn'); + if (!canReturnAction) { + return false; + } + return coerceBooleanProperty(canReturnAction.value); +} diff --git a/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.spec.ts b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.spec.ts new file mode 100644 index 000000000..ba0a15a71 --- /dev/null +++ b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.spec.ts @@ -0,0 +1,55 @@ +import { getReceiptItemAction } from './get-receipt-item-action.helper'; +import { ReceiptItem } from '../../models/receipt-item'; +import { Product } from '../../models/product'; +import { Quantity } from '../../models/quantity'; + +describe('getReceiptItemAction', () => { + const product: Product = { + name: 'Test Product', + contributors: 'Author', + catalogProductNumber: '123', + ean: '1234567890123', + format: 'Hardcover', + formatDetail: 'Detail', + volume: '1', + manufacturer: 'Test Publisher', + }; + const quantity: Quantity = { quantity: 1 }; + + const baseItem: ReceiptItem = { + id: 1, + product, + quantity, + receiptNumber: 'R-001', + actions: [], + }; + + it('should return undefined if actions property is missing', () => { + const item = { ...baseItem }; + delete (item as { actions?: unknown }).actions; + expect( + getReceiptItemAction(item as ReceiptItem, 'canReturn'), + ).toBeUndefined(); + }); + + it('should return undefined if no action with the given key exists', () => { + const item = { ...baseItem, actions: [{ key: 'other', value: 'true' }] }; + expect(getReceiptItemAction(item, 'canReturn')).toBeUndefined(); + }); + + it('should return the action object if the key exists', () => { + const action = { key: 'canReturn', value: 'true' }; + const item = { + ...baseItem, + actions: [action, { key: 'other', value: 'false' }], + }; + expect(getReceiptItemAction(item, 'canReturn')).toBe(action); + }); + + it('should return the first matching action if multiple exist', () => { + const action1 = { key: 'canReturn', value: 'true' }; + const action2 = { key: 'canReturn', value: 'false' }; + const item = { ...baseItem, actions: [action1, action2] }; + expect(getReceiptItemAction(item, 'canReturn')).toBe(action1); + }); +}); diff --git a/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.ts b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.ts new file mode 100644 index 000000000..983a4bac0 --- /dev/null +++ b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.ts @@ -0,0 +1,24 @@ +import { KeyValueDTOOfStringAndString } from '@generated/swagger/oms-api'; +import { ReceiptItem } from '../../models'; + +/** + * Retrieves a specific action object from a receipt item's actions array by key. + * + * @param receiptItem - The receipt item containing the actions array. + * @param actionKey - The key of the action to retrieve. + * @returns The action object with the specified key, or undefined if not found or if actions are missing. + * + * @remarks + * - Returns undefined if the 'actions' property is missing or not an array. + * - Returns undefined if no action with the given key exists. + */ +export function getReceiptItemAction( + receiptItem: ReceiptItem, + actionKey: string, +): KeyValueDTOOfStringAndString | undefined { + if ('actions' in receiptItem === false) { + return undefined; + } + + return receiptItem.actions?.find((a) => a.key === actionKey); +} diff --git a/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.spec.ts b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.spec.ts new file mode 100644 index 000000000..fd4e9d462 --- /dev/null +++ b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.spec.ts @@ -0,0 +1,55 @@ +import { getReceiptItemProductCategory } from './get-receipt-item-product-category.helper'; +import { ReceiptItem } from '../../models/receipt-item'; +import { Product } from '../../models/product'; +import { Quantity } from '../../models/quantity'; +import { ProductCategory } from '../../questions/constants'; + +describe('getReceiptItemProductCategory', () => { + const product: Product = { + name: 'Test Product', + contributors: 'Author', + catalogProductNumber: '123', + ean: '1234567890123', + format: 'Hardcover', + formatDetail: 'Detail', + volume: '1', + manufacturer: 'Test Publisher', + }; + const quantity: Quantity = { quantity: 1 }; + + const baseItem: ReceiptItem = { + id: 1, + product, + quantity, + receiptNumber: 'R-001', + features: {}, + }; + + it('should return ProductCategory.Unknown if features property is missing', () => { + const item = { ...baseItem }; + delete (item as { features?: unknown }).features; + expect(getReceiptItemProductCategory(item as ReceiptItem)).toBe( + ProductCategory.Unknown, + ); + }); + + it('should return ProductCategory.Unknown if category is not set', () => { + const item = { ...baseItem, features: {} }; + expect(getReceiptItemProductCategory(item)).toBe(ProductCategory.Unknown); + }); + + it('should return the category if set in features', () => { + const item = { + ...baseItem, + features: { category: ProductCategory.BookCalendar }, + }; + expect(getReceiptItemProductCategory(item)).toBe( + ProductCategory.BookCalendar, + ); + }); + + it('should return ProductCategory.Unknown if category is set to a falsy value', () => { + const item = { ...baseItem, features: { category: '' } }; + expect(getReceiptItemProductCategory(item)).toBe(ProductCategory.Unknown); + }); +}); diff --git a/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.ts b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.ts new file mode 100644 index 000000000..9e6075560 --- /dev/null +++ b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.ts @@ -0,0 +1,23 @@ +import { ReceiptItem } from '../../models'; +import { ProductCategory } from '../../questions'; + +/** + * Retrieves the product category for a given receipt item. + * + * @param item - The receipt item to extract the product category from. + * @returns The product category if present in the item's features; otherwise, ProductCategory.Unknown. + * + * @remarks + * - Returns ProductCategory.Unknown if the 'features' property is missing or does not contain a 'category'. + * - Casts the 'category' feature to ProductCategory if present. + */ +export function getReceiptItemProductCategory( + item: ReceiptItem, +): ProductCategory { + if (!item.features) { + return ProductCategory.Unknown; + } + return ( + (item.features['category'] as ProductCategory) || ProductCategory.Unknown + ); +} diff --git a/libs/oms/data-access/src/lib/helpers/return-process/index.ts b/libs/oms/data-access/src/lib/helpers/return-process/index.ts index 8c79f7250..2c034a299 100644 --- a/libs/oms/data-access/src/lib/helpers/return-process/index.ts +++ b/libs/oms/data-access/src/lib/helpers/return-process/index.ts @@ -1,10 +1,14 @@ export * from './active-return-process-questions.helper'; export * from './all-return-process-questions-answered.helper'; export * from './calculate-longest-question-depth.helper'; -export * from './get-next-question.helper'; -export * from './get-return-info.helper'; +export * from './can-return-receipt-item.helper'; export * from './eligible-for-return.helper'; +export * from './get-next-question.helper'; +export * from './get-receipt-item-action.helper'; +export * from './get-receipt-item-product-category.helper'; +export * from './get-return-info.helper'; export * from './get-return-process-questions.helper'; -export * from './return-receipt-values-mapping.helper'; -export * from './return-details-mapping.helper'; export * from './get-tolino-questions.helper'; +export * from './receipt-item-has-category.helper'; +export * from './return-details-mapping.helper'; +export * from './return-receipt-values-mapping.helper'; diff --git a/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.spec.ts b/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.spec.ts new file mode 100644 index 000000000..ee356f33f --- /dev/null +++ b/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.spec.ts @@ -0,0 +1,62 @@ +import { receiptItemHasCategory } from './receipt-item-has-category.helper'; +import { ReceiptItem } from '../../models/receipt-item'; +import { Product } from '../../models/product'; +import { Quantity } from '../../models/quantity'; +import { ProductCategory } from '../../questions/constants'; + +describe('receiptItemHasCategory', () => { + const product: Product = { + name: 'Test Product', + contributors: 'Author', + catalogProductNumber: '123', + ean: '1234567890123', + format: 'Hardcover', + formatDetail: 'Detail', + volume: '1', + manufacturer: 'Test Publisher', + }; + const quantity: Quantity = { quantity: 1 }; + + const baseItem: ReceiptItem = { + id: 1, + product, + quantity, + receiptNumber: 'R-001', + features: {}, + }; + + it('should return false if features property is missing', () => { + const item = { ...baseItem }; + delete (item as { features?: unknown }).features; + expect( + receiptItemHasCategory(item as ReceiptItem, ProductCategory.BookCalendar), + ).toBe(false); + }); + + it('should return false if category does not match', () => { + const item = { + ...baseItem, + features: { category: ProductCategory.Tolino }, + }; + expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe( + false, + ); + }); + + it('should return true if category matches', () => { + const item = { + ...baseItem, + features: { category: ProductCategory.BookCalendar }, + }; + expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe( + true, + ); + }); + + it('should return false if category is missing in features', () => { + const item = { ...baseItem, features: {} }; + expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe( + false, + ); + }); +}); diff --git a/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.ts b/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.ts new file mode 100644 index 000000000..bbc4a0a7f --- /dev/null +++ b/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.ts @@ -0,0 +1,24 @@ +import { ReceiptItem } from '../../models'; +import { ProductCategory } from '../../questions'; + +/** + * Checks if a receipt item has the specified product category. + * + * @param item - The receipt item to check. + * @param category - The product category to compare against. + * @returns True if the item's features contain the specified category, otherwise false. + * + * @remarks + * - Returns false if the 'features' property is missing. + * - Performs a strict equality check between the item's category and the provided category. + */ +export function receiptItemHasCategory( + item: ReceiptItem, + category: ProductCategory, +) { + if (!item.features) { + return false; + } + const itemCategory = item.features['category']; + return itemCategory === category; +} diff --git a/libs/oms/data-access/src/lib/models/index.ts b/libs/oms/data-access/src/lib/models/index.ts index 6bd66f572..0e6d3d6e4 100644 --- a/libs/oms/data-access/src/lib/models/index.ts +++ b/libs/oms/data-access/src/lib/models/index.ts @@ -5,6 +5,7 @@ export * from './eligible-for-return'; export * from './gender'; export * from './product'; export * from './quantity'; +export * from './receipt-item-list-item'; export * from './receipt-item-task-list-item'; export * from './receipt-item'; export * from './receipt-list-item'; @@ -17,5 +18,6 @@ export * from './return-process-question-type'; export * from './return-process-question'; export * from './return-process-status'; export * from './return-process'; +export * from './shipping-address-2'; export * from './shipping-type'; export * from './task-action-type'; diff --git a/libs/oms/data-access/src/lib/models/receipt-item-list-item.ts b/libs/oms/data-access/src/lib/models/receipt-item-list-item.ts new file mode 100644 index 000000000..49de292e9 --- /dev/null +++ b/libs/oms/data-access/src/lib/models/receipt-item-list-item.ts @@ -0,0 +1,10 @@ +import { ReceiptItemListItemDTO } from '@generated/swagger/oms-api'; +import { Product } from './product'; +import { Quantity } from './quantity'; + +export interface ReceiptItemListItem extends ReceiptItemListItemDTO { + id: number; + product: Product; + quantity: Quantity; + receiptItemNumber: string; +} diff --git a/libs/oms/data-access/src/lib/models/receipt-item.ts b/libs/oms/data-access/src/lib/models/receipt-item.ts index 03d150730..ca691f5f0 100644 --- a/libs/oms/data-access/src/lib/models/receipt-item.ts +++ b/libs/oms/data-access/src/lib/models/receipt-item.ts @@ -6,4 +6,5 @@ export interface ReceiptItem extends ReceiptItemDTO { id: number; product: Product; quantity: Quantity; + receiptNumber: string; } diff --git a/libs/oms/data-access/src/lib/models/receipt-list-item.ts b/libs/oms/data-access/src/lib/models/receipt-list-item.ts index 77a6578b9..04ac867e8 100644 --- a/libs/oms/data-access/src/lib/models/receipt-list-item.ts +++ b/libs/oms/data-access/src/lib/models/receipt-list-item.ts @@ -2,4 +2,5 @@ import { ReceiptListItemDTO } from '@generated/swagger/oms-api'; export interface ReceiptListItem extends ReceiptListItemDTO { id: number; + receiptNumber: string; } diff --git a/libs/oms/data-access/src/lib/models/receipt.ts b/libs/oms/data-access/src/lib/models/receipt.ts index 428f536a0..d36e8a035 100644 --- a/libs/oms/data-access/src/lib/models/receipt.ts +++ b/libs/oms/data-access/src/lib/models/receipt.ts @@ -2,9 +2,11 @@ import { ReceiptDTO } from '@generated/swagger/oms-api'; import { EntityContainer } from '@isa/common/data-access'; import { ReceiptItem } from './receipt-item'; import { Buyer } from './buyer'; +import { ShippingAddress2 } from './shipping-address-2'; export interface Receipt extends ReceiptDTO { id: number; items: EntityContainer[]; buyer: Buyer; + shipping?: ShippingAddress2; } diff --git a/libs/oms/data-access/src/lib/models/shipping-address-2.ts b/libs/oms/data-access/src/lib/models/shipping-address-2.ts new file mode 100644 index 000000000..4ad06037a --- /dev/null +++ b/libs/oms/data-access/src/lib/models/shipping-address-2.ts @@ -0,0 +1,3 @@ +import { ShippingAddressDTO2 } from '@generated/swagger/oms-api'; + +export type ShippingAddress2 = ShippingAddressDTO2; diff --git a/libs/oms/data-access/src/lib/operators/index.ts b/libs/oms/data-access/src/lib/operators/index.ts new file mode 100644 index 000000000..5c6ce6584 --- /dev/null +++ b/libs/oms/data-access/src/lib/operators/index.ts @@ -0,0 +1 @@ +export * from './take-until-aborted'; diff --git a/libs/oms/data-access/src/lib/operators/take-until-aborted.ts b/libs/oms/data-access/src/lib/operators/take-until-aborted.ts new file mode 100644 index 000000000..8b522afe9 --- /dev/null +++ b/libs/oms/data-access/src/lib/operators/take-until-aborted.ts @@ -0,0 +1,50 @@ +import { Observable } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +/** + * Creates an Observable that emits when an AbortSignal is aborted. + * + * @param signal - The AbortSignal instance to listen to + * @returns An Observable that emits and completes when the signal is aborted + */ +export const fromAbortSignal = (signal: AbortSignal): Observable => { + // If the signal is already aborted, return an Observable that immediately completes + if (signal.aborted) { + return new Observable((subscriber) => { + subscriber.complete(); + }); + } + + // Otherwise, create an Observable from the abort event + return new Observable((subscriber) => { + const abortHandler = () => { + subscriber.next(); + subscriber.complete(); + }; + + // Listen for the 'abort' event + signal.addEventListener('abort', abortHandler); + + // Clean up the event listener when the Observable is unsubscribed + return () => { + signal.removeEventListener('abort', abortHandler); + }; + }); +}; + +/** + * Operator that completes the source Observable when the provided AbortSignal is aborted. + * Similar to takeUntil, but works with AbortSignal instead of an Observable. + * + * @param signal - The AbortSignal instance that will trigger completion when aborted + * @returns An Observable that completes when the source completes or when the signal is aborted + */ +export const takeUntilAborted = + (signal: AbortSignal) => + (source: Observable): Observable => { + // Convert the AbortSignal to an Observable + const aborted$ = fromAbortSignal(signal); + + // Use the standard takeUntil operator with our abort Observable + return source.pipe(takeUntil(aborted$)); + }; diff --git a/libs/oms/data-access/src/lib/questions/constants.ts b/libs/oms/data-access/src/lib/questions/constants.ts index 2d671cad5..115408cad 100644 --- a/libs/oms/data-access/src/lib/questions/constants.ts +++ b/libs/oms/data-access/src/lib/questions/constants.ts @@ -5,6 +5,7 @@ import { ReturnProcessQuestionKey } from '../models'; * Constants for product categories used in the return process. */ export const ProductCategory = { + Unknown: 'unknown', BookCalendar: 'Buch/Kalender', TonDatentraeger: 'Ton-/Datenträger', SpielwarenPuzzle: 'Spielwaren/Puzzle', diff --git a/libs/oms/data-access/src/lib/questions/registry.ts b/libs/oms/data-access/src/lib/questions/registry.ts index a4fa21aa8..ecd104298 100644 --- a/libs/oms/data-access/src/lib/questions/registry.ts +++ b/libs/oms/data-access/src/lib/questions/registry.ts @@ -26,4 +26,5 @@ export const CategoryQuestions: Record< [ProductCategory.SonstigesNonbook]: nonbookQuestions, [ProductCategory.ElektronischeGeraete]: elektronischeGeraeteQuestions, [ProductCategory.Tolino]: tolinoQuestions, + [ProductCategory.Unknown]: [], }; diff --git a/libs/oms/data-access/src/lib/services/return-can-return.service.ts b/libs/oms/data-access/src/lib/services/return-can-return.service.ts index 8ef9041d5..3aa54fe8d 100644 --- a/libs/oms/data-access/src/lib/services/return-can-return.service.ts +++ b/libs/oms/data-access/src/lib/services/return-can-return.service.ts @@ -12,6 +12,7 @@ import { returnReceiptValuesMapping, } from '../helpers/return-process'; import { isReturnProcessTypeGuard } from '../guards'; +import { takeUntilAborted } from '@isa/common/data-access'; /** * Service for determining if a return process can proceed based on @@ -35,14 +36,20 @@ export class ReturnCanReturnService { * @param returnProcess - The return process object to evaluate. * @returns A promise resolving to a CanReturn result or undefined if the process should continue. */ - async canReturn(returnProcess: ReturnProcess): Promise; + async canReturn( + returnProcess: ReturnProcess, + abortSignal?: AbortSignal, + ): Promise; /** * Determines if a return can proceed based on mapped receipt values. * * @param returnValues - The mapped return receipt values. * @returns A promise resolving to a CanReturn result. */ - async canReturn(returnValues: ReturnReceiptValues): Promise; + async canReturn( + returnValues: ReturnReceiptValues, + abortSignal?: AbortSignal, + ): Promise; /** * Determines if a return can proceed, accepting either a ReturnProcess or ReturnReceiptValues. @@ -53,6 +60,7 @@ export class ReturnCanReturnService { */ async canReturn( input: ReturnProcess | ReturnReceiptValues, + abortSignal?: AbortSignal, ): Promise { let data: ReturnReceiptValues | undefined = undefined; @@ -66,14 +74,20 @@ export class ReturnCanReturnService { return undefined; // Prozess soll weitergehen, daher kein Error } + let req$ = this.#receiptService.ReceiptCanReturn( + data as ReturnReceiptValuesDTO, + ); + + if (abortSignal) { + req$ = req$.pipe(takeUntilAborted(abortSignal)); + } + try { return await firstValueFrom( - this.#receiptService - .ReceiptCanReturn(data as ReturnReceiptValuesDTO) - .pipe( - debounceTime(50), - map((res) => res as CanReturn), - ), + req$.pipe( + debounceTime(50), + map((res) => res as CanReturn), + ), ); } catch (error) { throw new Error(`ReceiptCanReturn failed: ${String(error)}`); diff --git a/libs/oms/data-access/src/lib/services/return-details.service.spec.ts b/libs/oms/data-access/src/lib/services/return-details.service.spec.ts index e7b8b1e93..15ccd1737 100644 --- a/libs/oms/data-access/src/lib/services/return-details.service.spec.ts +++ b/libs/oms/data-access/src/lib/services/return-details.service.spec.ts @@ -1,7 +1,10 @@ import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; import { ReturnDetailsService } from './return-details.service'; -import { ReceiptService } from '@generated/swagger/oms-api'; -import { of } from 'rxjs'; +import { + ReceiptService, + ResponseArgsOfReceiptDTO, +} from '@generated/swagger/oms-api'; +import { of, NEVER } from 'rxjs'; import { FetchReturnDetails } from '../schemas'; import { Receipt } from '../models'; @@ -16,7 +19,7 @@ describe('ReturnDetailsService', () => { spectator = createService(); }); - it('should fetch return details successfully', (done) => { + it('should fetch return details successfully', async () => { // Arrange const mockParams: FetchReturnDetails = { receiptId: 123 }; const mockResponse: any = { result: { id: 123, data: 'mockData' } }; @@ -24,14 +27,12 @@ describe('ReturnDetailsService', () => { receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse)); // Act - spectator.service.fetchReturnDetails(mockParams).subscribe((result) => { - // Assert - expect(result).toEqual(mockResponse.result as Receipt); - done(); - }); + const result = await spectator.service.fetchReturnDetails(mockParams); + + expect(result).toEqual(mockResponse.result as Receipt); }); - it('should throw an error if API response contains an error', (done) => { + it('should throw an error if API response contains an error', async () => { // Arrange const mockParams: FetchReturnDetails = { receiptId: 123 }; const mockResponse: any = { error: 'API error' }; @@ -39,26 +40,72 @@ describe('ReturnDetailsService', () => { receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse)); // Act - spectator.service.fetchReturnDetails(mockParams).subscribe({ - error: (err) => { - // Assert - expect(err.message).toBe('Failed to fetch return details'); - done(); - }, - }); + try { + await spectator.service.fetchReturnDetails(mockParams); + } catch (err) { + expect((err as Error).message).toBe('Failed to fetch return details'); + } }); - it('should throw an error if parameters are invalid', (done) => { + /** + * Should return undefined or throw if API returns an empty object. + */ + it('should handle empty API response gracefully', async () => { // Arrange - const invalidParams: any = { receiptId: null }; + const mockParams: FetchReturnDetails = { receiptId: 123 }; + const mockResponse: ResponseArgsOfReceiptDTO = { + error: false, + result: undefined, + }; + const receiptService = spectator.inject(ReceiptService); + receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse)); + + // Act & Assert + await expect( + spectator.service.fetchReturnDetails(mockParams), + ).rejects.toThrow('Failed to fetch return details'); + }); + + /** + * Should call ReceiptGetReceipt with correct parameters. + */ + it('should call ReceiptGetReceipt with correct params', async () => { + // Arrange + const mockParams: FetchReturnDetails = { receiptId: 456 }; + const mockResponse: ResponseArgsOfReceiptDTO = { + error: false, + result: { id: 456, data: 'mockData' } as unknown as Receipt, + }; + const receiptService = spectator.inject(ReceiptService); + const spy = jest.spyOn(receiptService, 'ReceiptGetReceipt'); + spy.mockReturnValue(of(mockResponse)); // Act - spectator.service.fetchReturnDetails(invalidParams).subscribe({ - error: (err) => { - // Assert - expect(err).toBeTruthy(); - done(); - }, - }); + await spectator.service.fetchReturnDetails(mockParams); + + // Assert + expect(spy).toHaveBeenCalledWith({ ...mockParams, eagerLoading: 2 }); + }); + + /** + * Should handle observable that never emits (simulate hanging request). + */ + it('should handle observable that never emits', async () => { + // Arrange + const mockParams: FetchReturnDetails = { receiptId: 789 }; + const receiptService = spectator.inject(ReceiptService); + // Simulate never emitting observable + receiptService.ReceiptGetReceipt.mockReturnValue(NEVER); + + // Act & Assert + // Should timeout or hang, so we expect the promise not to resolve + // For test safety, wrap in a Promise.race with a timeout + const fetchPromise = spectator.service.fetchReturnDetails(mockParams); + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 100), + ); + await expect(Promise.race([fetchPromise, timeout])).rejects.toThrow( + 'Timeout', + ); }); }); diff --git a/libs/oms/data-access/src/lib/services/return-details.service.ts b/libs/oms/data-access/src/lib/services/return-details.service.ts index f5286f48a..6965592ea 100644 --- a/libs/oms/data-access/src/lib/services/return-details.service.ts +++ b/libs/oms/data-access/src/lib/services/return-details.service.ts @@ -4,27 +4,43 @@ import { FetchReturnDetailsSchema, ReturnReceiptValues, } from '../schemas'; -import { map, Observable, throwError } from 'rxjs'; +import { firstValueFrom } from 'rxjs'; import { ReceiptService } from '@generated/swagger/oms-api'; -import { Receipt, ReceiptItem } from '../models'; -import { CategoryQuestions } from '../questions'; +import { CanReturn, Receipt, ReceiptItem, ReceiptListItem } from '../models'; +import { CategoryQuestions, ProductCategory } from '../questions'; import { KeyValue } from '@angular/common'; import { ReturnCanReturnService } from './return-can-return.service'; +import { takeUntilAborted } from '@isa/common/data-access'; +import { z } from 'zod'; +/** + * Service responsible for managing receipt return details and operations. + * + * This service provides functionality to: + * - Check if items are eligible for return + * - Fetch receipt details by receipt ID + * - Query receipts by customer email + * - Get available product categories for returns + */ @Injectable({ providedIn: 'root' }) export class ReturnDetailsService { #receiptService = inject(ReceiptService); #returnCanReturnService = inject(ReturnCanReturnService); - /** * Determines if a specific receipt item can be returned for a given category. * * @param params - The parameters for the return check. * @param params.item - The receipt item to check. * @param params.category - The product category to check against. - * @returns A promise resolving to the result of the canReturn check. + * @param abortSignal - Optional AbortSignal to cancel the request. + * @returns A promise resolving to the result of the canReturn check, containing + * eligibility status and any relevant constraints or messages. + * @throws Will throw an error if the return check fails or is aborted. */ - async canReturn({ item, category }: { item: ReceiptItem; category: string }) { + async canReturn( + { item, category }: { item: ReceiptItem; category: ProductCategory }, + abortSignal?: AbortSignal, + ): Promise { const returnReceiptValues: ReturnReceiptValues = { quantity: item.quantity.quantity, receiptItem: { @@ -33,44 +49,107 @@ export class ReturnDetailsService { category, }; - return await this.#returnCanReturnService.canReturn(returnReceiptValues); + return this.#returnCanReturnService.canReturn( + returnReceiptValues, + abortSignal, + ); } - /** * Gets all available product categories that have defined question sets. * - * @returns {KeyValue[]} Array of key-value pairs representing available categories. + * This method filters out the "Unknown" category and returns all other + * categories defined in the CategoryQuestions object. + * + * @returns {KeyValue[]} Array of key-value pairs representing + * available categories, where the key is the ProductCategory enum value + * and the value is the string representation. */ - availableCategories(): KeyValue[] { - return Object.keys(CategoryQuestions).map((key) => { - return { key, value: key }; + availableCategories(): KeyValue[] { + const categories = Object.keys(CategoryQuestions).map((key) => { + return { key: key as ProductCategory, value: key }; }); - } + return categories.filter( + (category) => category.key !== ProductCategory.Unknown, + ); + } /** * Fetches the return details for a specific receipt. * + * This method retrieves detailed information about a receipt using its ID. + * The data is validated using Zod schema validation before making the API call. + * * @param params - The parameters required to fetch the return details, including the receipt ID. - * @returns An observable that emits the fetched receipt details. + * @param abortSignal - Optional AbortSignal that can be used to cancel the request. + * @returns A promise that resolves to the fetched receipt details. * @throws Will throw an error if the parameters are invalid or if the API response contains an error. */ - fetchReturnDetails(params: FetchReturnDetails): Observable { - try { - const parsed = FetchReturnDetailsSchema.parse(params); + async fetchReturnDetails( + params: FetchReturnDetails, + abortSignal?: AbortSignal, + ): Promise { + const parsed = FetchReturnDetailsSchema.parse(params); - return this.#receiptService - .ReceiptGetReceipt({ receiptId: parsed.receiptId, eagerLoading: 2 }) - .pipe( - map((res) => { - if (res.error || !res.result) { - throw new Error(res.message || 'Failed to fetch return details'); - } + let req$ = this.#receiptService.ReceiptGetReceipt({ + receiptId: parsed.receiptId, + eagerLoading: 2, + }); - return res.result as Receipt; - }), - ); - } catch (error) { - return throwError(() => error); + if (abortSignal) { + req$ = req$.pipe(takeUntilAborted(abortSignal)); } + + const res = await firstValueFrom(req$); + + if (res.error || !res.result) { + throw new Error(res.message || 'Failed to fetch return details'); + } + + return res.result as Receipt; + } + /** + * Schema definition for email-based receipt query parameters. + * Validates that the email parameter is a properly formatted email address. + */ + static FetchReceiptsEmailParamsSchema = z.object({ + email: z.string().email(), + }); + + /** + * Fetches receipts associated with a specific email address. + * + * This method queries the receipt service for receipts that match the provided email address. + * The email is validated using Zod schema validation before making the API call. + * + * @param params - The parameters containing the email to search for. + * @param params.email - Email address to search for in receipt records. + * @param abortSignal - Optional AbortSignal that can be used to cancel the request. + * @returns A promise that resolves to an array of receipt list items matching the email. + * @throws Will throw an error if the email is invalid or if the API response contains an error. + */ + async fetchReceiptsByEmail( + params: z.infer, + abortSignal?: AbortSignal, + ): Promise { + const { email } = + ReturnDetailsService.FetchReceiptsEmailParamsSchema.parse(params); + + let req$ = this.#receiptService.ReceiptQueryReceipt({ + queryToken: { + input: { qs: email }, + filter: { receipt_type: '1;128;1024' }, + }, + }); + + if (abortSignal) { + req$ = req$.pipe(takeUntilAborted(abortSignal)); + } + + const res = await firstValueFrom(req$); + if (res.error || !res.result) { + throw new Error(res.message || 'Failed to fetch return items by email'); + } + + return res.result as ReceiptListItem[]; } } diff --git a/libs/oms/data-access/src/lib/stores/return-details.store.spec.ts b/libs/oms/data-access/src/lib/stores/return-details.store.spec.ts index 1a28ceaf4..99923e377 100644 --- a/libs/oms/data-access/src/lib/stores/return-details.store.spec.ts +++ b/libs/oms/data-access/src/lib/stores/return-details.store.spec.ts @@ -1,169 +1,57 @@ import { createServiceFactory } from '@ngneat/spectator/jest'; -import { ReturnDetailsStore } from './return-details.store'; +import { receiptConfig, ReturnDetailsStore } from './return-details.store'; import { ReturnDetailsService } from '../services'; +import { SessionStorageProvider } from '@isa/core/storage'; +import { LoggingService } from '@isa/core/logging'; import { patchState } from '@ngrx/signals'; -import { AsyncResultStatus } from '@isa/common/data-access'; -import { addEntity } from '@ngrx/signals/entities'; -import { Receipt } from '../models'; -import { of, throwError } from 'rxjs'; +import { unprotected } from '@ngrx/signals/testing'; +import { Receipt, ReceiptItem } from '../models'; +import { addEntities } from '@ngrx/signals/entities'; describe('ReturnDetailsStore', () => { const createService = createServiceFactory({ service: ReturnDetailsStore, - mocks: [ReturnDetailsService], + mocks: [ReturnDetailsService, SessionStorageProvider, LoggingService], }); - describe('Initialization', () => { - it('should create an instance of ReturnDetailsStore', () => { - const spectator = createService(); - expect(spectator.service).toBeTruthy(); - }); + it('should create an instance of ReturnDetailsStore', () => { + const spectator = createService(); + expect(spectator.service).toBeTruthy(); }); - describe('Entity Management', () => { - describe('beforeFetch', () => { - it('should create a new entity and set status to Pending if it does not exist', () => { - const spectator = createService(); - const receiptId = 123; - spectator.service.beforeFetch(receiptId); - - expect(spectator.service.entityMap()[123]).toEqual({ - id: receiptId, - data: undefined, - status: AsyncResultStatus.Pending, - }); - }); - - it('should update the existing entity status to Pending', () => { - const spectator = createService(); - const receiptId = 123; - - const data = {}; - - patchState( - spectator.service as any, - addEntity({ id: receiptId, data, status: AsyncResultStatus.Idle }), - ); - - spectator.service.beforeFetch(receiptId); - - expect(spectator.service.entityMap()[123]).toEqual({ - id: receiptId, - data, - status: AsyncResultStatus.Pending, - }); - }); - }); - - describe('fetchSuccess', () => { - it('should update the entity with fetched data and set status to Success', () => { - const spectator = createService(); - const receiptId = 123; - const data: Receipt = { - id: receiptId, - items: [], - buyer: { buyerNumber: '321' }, - }; - - patchState( - spectator.service as any, - addEntity({ - id: receiptId, - data: undefined, - status: AsyncResultStatus.Pending, - }), - ); - - spectator.service.fetchSuccess(receiptId, data); - - expect(spectator.service.entityMap()[123]).toEqual({ - id: receiptId, - data, - status: AsyncResultStatus.Success, - }); - }); - }); - - describe('fetchError', () => { - it('should update the entity status to Error', () => { - const spectator = createService(); - const receiptId = 123; - const error = new Error('Fetch error'); - - patchState( - spectator.service as any, - addEntity({ - id: receiptId, - data: undefined, - status: AsyncResultStatus.Pending, - }), - ); - - spectator.service.fetchError(receiptId, error); - - const entity = spectator.service.entityMap()[123]; - expect(entity).toMatchObject({ - id: receiptId, - status: AsyncResultStatus.Error, - error, - }); - }); - }); - }); - - describe('fetch', () => { - it('should call the service and update the store on success', () => { + describe('items', () => { + it('should return the items from the receiptsEntities', () => { + // Arrange const spectator = createService(); - const receiptId = 123; - const data: Receipt = { - id: receiptId, - items: [], - buyer: { buyerNumber: '321' }, - }; - spectator.service.beforeFetch(receiptId); - spectator - .inject(ReturnDetailsService) - .fetchReturnDetails.mockReturnValueOnce(of(data)); + const receiptItems = [ + { id: 1 } as ReceiptItem, + { id: 2 } as ReceiptItem, + { id: 3 } as ReceiptItem, + { id: 4 } as ReceiptItem, + ]; - spectator.service.fetch({ receiptId }); + const receiptsEntities = [ + { + id: 1, + items: [{ data: receiptItems[0] }, { data: receiptItems[1] }], + } as Receipt, + { + id: 2, + items: [{ data: receiptItems[2] }, { data: receiptItems[3] }], + } as Receipt, + ] as Receipt[]; - expect( - spectator.inject(ReturnDetailsService).fetchReturnDetails, - ).toHaveBeenCalledWith({ - receiptId, - }); + patchState( + unprotected(spectator.service), + addEntities(receiptsEntities, receiptConfig), + ); - expect(spectator.service.entityMap()[123]).toEqual({ - id: receiptId, - data, - status: AsyncResultStatus.Success, - }); - }); + // Act + const items = spectator.service.items(); - it('should handle errors and update the store accordingly', () => { - const spectator = createService(); - const receiptId = 123; - const error = new Error('Fetch error'); - - spectator.service.beforeFetch(receiptId); - spectator - .inject(ReturnDetailsService) - .fetchReturnDetails.mockReturnValueOnce(throwError(() => error)); - - spectator.service.fetch({ receiptId }); - - expect( - spectator.inject(ReturnDetailsService).fetchReturnDetails, - ).toHaveBeenCalledWith({ - receiptId, - }); - const entity = spectator.service.entityMap()[123]; - expect(entity).toMatchObject({ - id: receiptId, - status: AsyncResultStatus.Error, - error, - }); + // Assert + expect(items).toEqual(receiptItems); }); }); }); diff --git a/libs/oms/data-access/src/lib/stores/return-details.store.ts b/libs/oms/data-access/src/lib/stores/return-details.store.ts index ed9619417..b60dfbb2b 100644 --- a/libs/oms/data-access/src/lib/stores/return-details.store.ts +++ b/libs/oms/data-access/src/lib/stores/return-details.store.ts @@ -1,218 +1,267 @@ -import { patchState, signalStore, withMethods } from '@ngrx/signals'; -import { addEntity, updateEntity, withEntities } from '@ngrx/signals/entities'; -import { AsyncResult, AsyncResultStatus } from '@isa/common/data-access'; -import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { pipe, switchMap, tap } from 'rxjs'; -import { inject } from '@angular/core'; -import { ReturnDetailsService } from '../services'; -import { tapResponse } from '@ngrx/operators'; -import { Receipt } from '../models'; +import { computed, effect, inject, resource, untracked } from '@angular/core'; +import { + CanReturn, + ProductCategory, + Receipt, + ReceiptItem, + ReturnDetailsService, +} from '@isa/oms/data-access'; +import { + getState, + patchState, + signalStore, + type, + withComputed, + withMethods, + withProps, + withState, +} from '@ngrx/signals'; +import { setEntity, withEntities, entityConfig } from '@ngrx/signals/entities'; +import { + canReturnReceiptItem, + getReceiptItemProductCategory, + receiptItemHasCategory, +} from '../helpers/return-process'; +import { SessionStorageProvider } from '@isa/core/storage'; +import { logger } from '@isa/core/logging'; -/** - * Represents the result of a return operation, including the receipt data and status. - */ -export type ReturnResult = AsyncResult & { id: number }; +interface ReturnDetailsState { + _storageId: number | undefined; + _selectedItemIds: number[]; + selectedProductCategory: Record; + selectedQuantity: Record; + canReturn: Record; +} -/** - * Initial state for a return result entity, excluding the unique identifier. - */ -const initialEntity: Omit = { - data: undefined, - status: AsyncResultStatus.Idle, +const initialState: ReturnDetailsState = { + _storageId: undefined, + _selectedItemIds: [], + selectedProductCategory: {}, + selectedQuantity: {}, + canReturn: {}, }; -/** - * Store for managing return details using NgRx signals. - * Provides methods for fetching and updating return details. - */ +export const receiptConfig = entityConfig({ + entity: type(), + collection: 'receipts', +}); + export const ReturnDetailsStore = signalStore( { providedIn: 'root' }, - withEntities(), + withState(initialState), + withEntities(receiptConfig), + withProps(() => ({ + _logger: logger(() => ({ store: 'ReturnDetailsStore' })), + _returnDetailsService: inject(ReturnDetailsService), + _storage: inject(SessionStorageProvider), + })), withMethods((store) => ({ - /** - * Prepares the store before fetching return details by adding or updating the entity with a pending status. - * @param receiptId - The unique identifier of the receipt. - * @returns The updated or newly created entity. - */ - beforeFetch(receiptId: number) { - // Using optional chaining to safely retrieve the entity from the map - let entity: ReturnResult | undefined = store.entityMap()?.[receiptId]; - if (!entity) { - entity = { - ...initialEntity, - id: receiptId, - status: AsyncResultStatus.Pending, - }; - patchState(store, addEntity(entity)); + _storageKey: () => `ReturnDetailsStore:${store._storageId}`, + })), + withMethods((store) => ({ + _storeState: () => { + const state = getState(store); + if (!store._storageId) { + return; + } + store._storage.set(store._storageKey(), state); + store._logger.debug('State stored:', () => state); + }, + _restoreState: async () => { + const data = await store._storage.get(store._storageKey()); + if (data) { + patchState(store, data); + store._logger.debug('State restored:', () => ({ data })); } else { - patchState( - store, - updateEntity({ - id: receiptId, - changes: { status: AsyncResultStatus.Pending }, - }), - ); + patchState(store, { ...initialState, _storageId: store._storageId() }); + store._logger.debug('No state found, initialized with default state'); } }, - - /** - * Updates the store with the fetched return details on a successful fetch operation. - * @param receiptId - The unique identifier of the receipt. - * @param data - The fetched receipt data. - */ - fetchSuccess(receiptId: number, data: Receipt) { - patchState( - store, - updateEntity({ - id: receiptId, - changes: { data, status: AsyncResultStatus.Success }, - }), - ); - }, - - /** - * Updates the store with an error state if the fetch operation fails. - * @param receiptId - The unique identifier of the receipt. - * @param error - The error encountered during the fetch operation. - */ - fetchError(receiptId: number, error: unknown) { - patchState( - store, - updateEntity({ - id: receiptId, - changes: { error, status: AsyncResultStatus.Error }, - }), - ); - }, - - /** - * Updates the product quantity for a specific item in a receipt. - * This method modifies the quantity feature of an item within a receipt's data. - * - * @param params - Object containing parameters for the update - * @param params.receiptId - The unique identifier of the receipt containing the item - * @param params.itemId - The unique identifier of the item to update - * @param params.quantity - The new quantity value to assign to the item (defaults to 0 if falsy) - * @returns void - */ - updateProductQuantityForItem({ - receiptId, - itemId, - quantity, - }: { - receiptId: number; - itemId: number; - quantity: number; - }) { - const receipt = store.entityMap()?.[receiptId]; - if (!receipt) return; - - const updatedItems = receipt?.data?.items.map((item) => { - if (item.id !== itemId) { - return item; - } - - return { - ...item, - data: { - ...item.data, - quantity: { - ...item?.data?.quantity, - quantity: quantity || 0, - }, - }, - }; - }); - - patchState( - store, - updateEntity({ - id: receiptId, - changes: { - data: { - ...receipt.data, - items: updatedItems, - } as Receipt, - }, - }), - ); - }, - - /** - * Updates the product category for a specific item in a receipt. - * This method modifies the category feature of an item within a receipt's data. - * - * @param params - Object containing parameters for the update - * @param params.receiptId - The unique identifier of the receipt containing the item - * @param params.itemId - The unique identifier of the item to update - * @param params.category - The new category value to assign to the item (defaults to 'unknown' if falsy) - * @returns void - */ - updateProductCategoryForItem({ - receiptId, - itemId, - category, - }: { - receiptId: number; - itemId: number; - category: string; - }) { - const receipt = store.entityMap()?.[receiptId]; - if (!receipt) return; - - const updatedItems = receipt?.data?.items.map((item) => { - if (item.id !== itemId) { - return item; - } - - return { - ...item, - data: { - ...item.data, - features: { - ...item?.data?.features, - category: category || 'unknown', - }, - }, - }; - }); - - patchState( - store, - updateEntity({ - id: receiptId, - changes: { - data: { - ...receipt.data, - items: updatedItems, - } as Receipt, - }, - }), - ); - }, })), - withMethods((store, returnDetailsService = inject(ReturnDetailsService)) => ({ - /** - * Fetches return details for a given receipt ID. - * Updates the store with the appropriate state based on the fetch result. - * @param params - An object containing the receipt ID. - */ - fetch: rxMethod<{ receiptId: number }>( - pipe( - tap(({ receiptId }) => store.beforeFetch(receiptId)), - switchMap(({ receiptId }) => - returnDetailsService.fetchReturnDetails({ receiptId }).pipe( - tapResponse({ - next(value) { - store.fetchSuccess(receiptId, value); - }, - error(error) { - store.fetchError(receiptId, error); - }, - }), - ), - ), - ), + withComputed((store) => ({ + items: computed>(() => + store + .receiptsEntities() + .map((receipt) => receipt.items) + .flat() + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .map((container) => container.data!), ), })), + withComputed((store) => ({ + selectedItemIds: computed(() => { + const selectedIds = store._selectedItemIds(); + const canReturn = store.canReturn(); + + return selectedIds.filter((id) => { + const canReturnResult = canReturn[id]?.result; + return typeof canReturnResult === 'boolean' ? canReturnResult : true; + }); + }), + })), + withComputed((store) => ({ + returnableItems: computed(() => { + const items = store.items(); + return items.filter(canReturnReceiptItem); + }), + selectableItems: computed(() => { + const items = store.items(); + const selectedProductCategory = store.selectedProductCategory(); + return items.filter(canReturnReceiptItem).filter((item) => { + const category = selectedProductCategory[item.id]; + return ( + category || !receiptItemHasCategory(item, ProductCategory.Unknown) + ); + }); + }), + selectedItems: computed(() => { + const selectedIds = store.selectedItemIds(); + const items = store + .receiptsEntities() + .flatMap((receipt) => receipt.items.map((item) => item.data!)); + return items.filter((item) => selectedIds.includes(item.id)); + }), + })), + withMethods((store) => ({ + receiptResource: (receiptId: () => number | undefined) => + resource({ + request: receiptId, + loader: async ({ abortSignal, request }) => { + if (!request) { + return undefined; + } + const receipt = await store._returnDetailsService.fetchReturnDetails( + { receiptId: request }, + abortSignal, + ); + patchState(store, setEntity(receipt, receiptConfig)); + store._storeState(); + return receipt; + }, + }), + + canReturnResource: (receiptItem: () => ReceiptItem | undefined) => + resource({ + request: () => { + const item = receiptItem(); + + if (!item) { + return undefined; + } + + return { + item: item, + category: + store.selectedProductCategory()[item.id] || + getReceiptItemProductCategory(item), + }; + }, + loader: async ({ request, abortSignal }) => { + if (request === undefined) { + return undefined; + } + const key = `${request.item.id}:${request.category}`; + + if (store.canReturn()[key]) { + return store.canReturn()[key]; + } + + const res = await store._returnDetailsService.canReturn( + request, + abortSignal, + ); + patchState(store, { + canReturn: { ...store.canReturn(), [key]: res }, + }); + + store._storeState(); + return res; + }, + }), + getReceipt: (receiptId: () => number) => + computed(() => { + const id = receiptId(); + const entities = store.receiptsEntityMap(); + return entities[id]; + }), + getItems: (receiptId: () => number) => + computed(() => { + const id = receiptId(); + const entities = store.receiptsEntityMap(); + const receipt = entities[id]; + return receipt?.items.map((item) => item.data!); + }), + getSelectableItems: (receiptId: () => number) => + computed(() => + store + .selectableItems() + .filter((item) => item.receipt?.id === receiptId()), + ), + getItemSelected: (item: () => ReceiptItem) => + computed(() => { + const selectedIds = store.selectedItemIds(); + return selectedIds.includes(item().id); + }), + getItemQuantity: (item: () => ReceiptItem) => + computed(() => { + const itemData = item(); + return ( + store.selectedQuantity()[itemData.id] || itemData.quantity.quantity + ); + }), + getProductCategory: (item: () => ReceiptItem) => + computed(() => { + const itemData = item(); + return ( + store.selectedProductCategory()[itemData.id] || + getReceiptItemProductCategory(itemData) + ); + }), + isSelectable: (receiptItem: () => ReceiptItem) => + computed(() => { + const item = receiptItem(); + const selectableItems = store.selectableItems(); + return selectableItems.some((i) => i.id === item.id); + }), + getCanReturn: (item: () => ReceiptItem) => + computed(() => { + const itemData = item(); + return store.canReturn()[itemData.id]; + }), + })), + + withMethods((store) => ({ + selectStorage: (id: number) => { + untracked(() => { + patchState(store, { _storageId: id }); + store._restoreState(); + store._storeState(); + store._logger.debug('Storage ID set:', () => ({ id })); + }); + }, + addSelectedItems(itemIds: number[]) { + const currentIds = store.selectedItemIds(); + const newIds = Array.from(new Set([...currentIds, ...itemIds])); + patchState(store, { _selectedItemIds: newIds }); + store._storeState(); + }, + removeSelectedItems(itemIds: number[]) { + const currentIds = store.selectedItemIds(); + const newIds = currentIds.filter((id) => !itemIds.includes(id)); + patchState(store, { _selectedItemIds: newIds }); + store._storeState(); + }, + async setProductCategory(itemId: number, category: ProductCategory) { + const currentCategory = store.selectedProductCategory(); + const newCategory = { ...currentCategory, [itemId]: category }; + patchState(store, { selectedProductCategory: newCategory }); + store._storeState(); + }, + setQuantity(itemId: number, quantity: number) { + const currentQuantity = store.selectedQuantity(); + const newQuantity = { ...currentQuantity, [itemId]: quantity }; + patchState(store, { selectedQuantity: newQuantity }); + store._storeState(); + }, + })), ); diff --git a/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts b/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts index 7b6defb89..586ae6383 100644 --- a/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts +++ b/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts @@ -3,7 +3,8 @@ import { ReturnProcessStore } from './return-process.store'; import { IDBStorageProvider } from '@isa/core/storage'; import { ProcessService } from '@isa/core/process'; import { patchState } from '@ngrx/signals'; -import { setAllEntities } from '@ngrx/signals/entities'; +import { setAllEntities, setEntity } from '@ngrx/signals/entities'; +import { unprotected } from '@ngrx/signals/testing'; import { Product, Receipt, ReturnProcess } from '../models'; import { CreateReturnProcessError } from '../errors/return-process'; @@ -17,6 +18,7 @@ const TEST_ITEMS: Record = { formatDetail: 'Taschenbuch', } as Product, quantity: { quantity: 1 }, + receiptNumber: 'R-001', }, 2: { id: 2, @@ -27,6 +29,7 @@ const TEST_ITEMS: Record = { formatDetail: 'Buch', } as Product, quantity: { quantity: 1 }, + receiptNumber: 'R-002', }, 3: { id: 3, @@ -37,6 +40,7 @@ const TEST_ITEMS: Record = { formatDetail: 'Audio', } as Product, quantity: { quantity: 1 }, + receiptNumber: 'R-003', }, }; @@ -64,12 +68,39 @@ describe('ReturnProcessStore', () => { const store = spectator.service; patchState( - store as any, + unprotected(store), setAllEntities([ - { id: 1, processId: 1, name: 'Process 1' }, - { id: 2, processId: 2, name: 'Process 2' }, - { id: 3, processId: 1, name: 'Process 3' }, - ]), + { + id: 1, + processId: 1, + receiptId: 1, + receiptItem: TEST_ITEMS[1], + receiptDate: '', + answers: {}, + productCategory: undefined, + returnReceipt: undefined, + }, + { + id: 2, + processId: 2, + receiptId: 2, + receiptItem: TEST_ITEMS[2], + receiptDate: '', + answers: {}, + productCategory: undefined, + returnReceipt: undefined, + }, + { + id: 3, + processId: 1, + receiptId: 3, + receiptItem: TEST_ITEMS[3], + receiptDate: '', + answers: {}, + productCategory: undefined, + returnReceipt: undefined, + }, + ] as ReturnProcess[]), ); store.removeAllEntitiesByProcessId(1); @@ -82,8 +113,19 @@ describe('ReturnProcessStore', () => { const store = spectator.service; patchState( - store as any, - setAllEntities([{ id: 1, processId: 1, answers: {} }]), + unprotected(store), + setAllEntities([ + { + id: 1, + processId: 1, + receiptId: 1, + receiptItem: TEST_ITEMS[1], + receiptDate: '', + answers: {}, + productCategory: undefined, + returnReceipt: undefined, + }, + ] as ReturnProcess[]), ); store.setAnswer(1, 'question1', 'answer1'); @@ -95,10 +137,20 @@ describe('ReturnProcessStore', () => { const store = spectator.service; patchState( - store as any, - setAllEntities([ - { id: 1, processId: 1, answers: { question1: 'answer1' } }, - ]), + unprotected(store), + setEntity({ + id: 1, + processId: 1, + answers: { question1: 'answer1', question2: 'answer2' } as Record< + string, + unknown + >, + receiptDate: new Date().toJSON(), + receiptItem: TEST_ITEMS[1], + receiptId: 123, + productCategory: undefined, + returnReceipt: undefined, + } as ReturnProcess), ); store.removeAnswer(1, 'question1'); @@ -113,8 +165,26 @@ describe('ReturnProcessStore', () => { store.startProcess({ processId: 1, - receipt: {} as Receipt, - items: [TEST_ITEMS[1], TEST_ITEMS[3]], + returns: [ + { + receipt: { + id: 1, + printedDate: '', + items: [], + buyer: { buyerNumber: '' }, + }, + items: [TEST_ITEMS[1]], + }, + { + receipt: { + id: 2, + printedDate: '', + items: [], + buyer: { buyerNumber: '' }, + }, + items: [TEST_ITEMS[3]], + }, + ], }); expect(store.entities()).toHaveLength(2); @@ -127,8 +197,17 @@ describe('ReturnProcessStore', () => { expect(() => { store.startProcess({ processId: 1, - receipt: {} as Receipt, - items: [TEST_ITEMS[2]], + returns: [ + { + receipt: { + id: 2, + printedDate: '', + items: [], + buyer: { buyerNumber: '' }, + }, + items: [TEST_ITEMS[2]], // Non-returnable item + }, + ], }); }).toThrow(CreateReturnProcessError); }); @@ -140,8 +219,17 @@ describe('ReturnProcessStore', () => { expect(() => { store.startProcess({ processId: 1, - receipt: {} as Receipt, - items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]], + returns: [ + { + receipt: { + id: 3, + printedDate: '', + items: [], + buyer: { buyerNumber: '' }, + }, + items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]], + }, + ], }); }).toThrow(CreateReturnProcessError); }); diff --git a/libs/oms/data-access/src/lib/stores/return-process.store.ts b/libs/oms/data-access/src/lib/stores/return-process.store.ts index b9f9e4ff4..ef4444e0c 100644 --- a/libs/oms/data-access/src/lib/stores/return-process.store.ts +++ b/libs/oms/data-access/src/lib/stores/return-process.store.ts @@ -4,6 +4,7 @@ import { withComputed, withHooks, withMethods, + withProps, } from '@ngrx/signals'; import { withEntities, @@ -14,19 +15,19 @@ import { IDBStorageProvider, withStorage } from '@isa/core/storage'; import { computed, effect, inject } from '@angular/core'; import { ProcessService } from '@isa/core/process'; import { Receipt, ReceiptItem, ReturnProcess } from '../models'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { CreateReturnProcessError, CreateReturnProcessErrorReason, } from '../errors/return-process'; +import { logger } from '@isa/core/logging'; +import { canReturnReceiptItem } from '../helpers/return-process'; /** * Interface representing the parameters required to start a return process. */ export type StartProcess = { processId: number; - receipt: Receipt; - items: ReceiptItem[]; + returns: { receipt: Receipt; items: ReceiptItem[] }[]; }; /** @@ -57,6 +58,11 @@ export const ReturnProcessStore = signalStore( { providedIn: 'root' }, withStorage('return-process', IDBStorageProvider), withEntities(), + withProps(() => ({ + _logger: logger(() => ({ + store: 'ReturnProcessStore', + })), + })), withComputed((store) => ({ nextId: computed(() => Math.max(0, ...store.ids().map(Number)) + 1), })), @@ -134,38 +140,40 @@ export const ReturnProcessStore = signalStore( const entities: ReturnProcess[] = []; const nextId = store.nextId(); - const returnableItems = params.items.filter((item) => - item.actions?.some( - (a) => a.key === 'canReturn' && coerceBooleanProperty(a.value), - ), - ); + const returnableItems = params.returns + .flatMap((r) => r.items) + .filter(canReturnReceiptItem); if (returnableItems.length === 0) { - throw new CreateReturnProcessError( + const err = new CreateReturnProcessError( CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS, params, ); + store._logger.error(err.message, err); + throw err; } - if (returnableItems.length !== params.items.length) { - throw new CreateReturnProcessError( + if (returnableItems.length !== params.returns.length) { + const err = new CreateReturnProcessError( CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS, params, ); + store._logger.error(err.message, err); + throw err; } - for (let i = 0; i < params.items.length; i++) { - const item = params.items[i]; - - entities.push({ - id: nextId + i, - processId: params.processId, - receiptId: params.receipt.id, - productCategory: item.features?.['category'], - receiptDate: params.receipt.printedDate, - receiptItem: item, - answers: {}, - }); + for (const { receipt, items } of params.returns) { + for (const item of items) { + entities.push({ + id: nextId + entities.length, + processId: params.processId, + receiptId: receipt.id, + productCategory: item.features?.['category'], + receiptDate: receipt.printedDate, + receiptItem: item, + answers: {}, + }); + } } patchState(store, setAllEntities(entities)); diff --git a/libs/oms/data-access/src/lib/stores/return-search.store.spec.ts b/libs/oms/data-access/src/lib/stores/return-search.store.spec.ts index 48cad5888..87611aaf8 100644 --- a/libs/oms/data-access/src/lib/stores/return-search.store.spec.ts +++ b/libs/oms/data-access/src/lib/stores/return-search.store.spec.ts @@ -75,7 +75,7 @@ describe('ReturnSearchStore', () => { error: false, take: 10, invalidProperties: {}, - result: [{ id: 1 }], + result: [{ id: 1 } as ReceiptListItem], }; spectator.service.beforeSearch(1); @@ -111,7 +111,7 @@ describe('ReturnSearchStore', () => { error: false, take: 10, invalidProperties: {}, - result: [{ id: 1 }], + result: [{ id: 1 } as ReceiptListItem], }; const returnSearchService = spectator.inject(ReturnSearchService); returnSearchService.search.mockReturnValue(of(mockResponse)); diff --git a/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.html b/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.html index 7faa754cb..98b674489 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.html @@ -1,3 +1,5 @@ +@let r = receipt(); + Belegdatum: @@ -10,7 +12,7 @@ Belegart: - {{ receipt().receiptType | omsReceiptTypeTranslation }} + {{ r.receiptType | omsReceiptTypeTranslation }} diff --git a/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.ts b/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.ts index bfa4a571a..f2e0853a4 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.ts @@ -3,6 +3,9 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { Receipt } from '@isa/oms/data-access'; import { ReceiptTypeTranslationPipe } from '@isa/oms/utils/translation'; import { ItemRowDataImports } from '@isa/ui/item-rows'; + +export type ReceiptInput = Pick; + @Component({ selector: 'oms-feature-return-details-data', templateUrl: './return-details-data.component.html', @@ -11,5 +14,5 @@ import { ItemRowDataImports } from '@isa/ui/item-rows'; imports: [ItemRowDataImports, ReceiptTypeTranslationPipe, DatePipe], }) export class ReturnDetailsDataComponent { - receipt = input.required(); + receipt = input.required(); } diff --git a/libs/oms/feature/return-details/src/lib/return-details-header/return-details-header.component.ts b/libs/oms/feature/return-details/src/lib/return-details-header/return-details-header.component.ts index 5cad3d346..a047e85ec 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-header/return-details-header.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-header/return-details-header.component.ts @@ -6,13 +6,13 @@ import { input, } from '@angular/core'; import { isaActionChevronDown, isaNavigationKunden } from '@isa/icons'; -import { Buyer } from '@isa/oms/data-access'; import { InfoButtonComponent } from '@isa/ui/buttons'; import { NgIconComponent, provideIcons } from '@ng-icons/core'; import { formatName } from 'libs/oms/utils/format-name'; import { UiMenu } from '@isa/ui/menu'; import { RouterLink } from '@angular/router'; import { CdkMenuTrigger } from '@angular/cdk/menu'; +import { ReturnDetailsStore } from '@isa/oms/data-access'; @Component({ selector: 'oms-feature-return-details-header', @@ -30,7 +30,13 @@ import { CdkMenuTrigger } from '@angular/cdk/menu'; providers: [provideIcons({ isaNavigationKunden, isaActionChevronDown })], }) export class ReturnDetailsHeaderComponent { - buyer = input.required(); + #store = inject(ReturnDetailsStore); + + receiptId = input.required(); + + receipt = this.#store.getReceipt(this.receiptId); + + buyer = computed(() => this.receipt().buyer); referenceId = computed(() => { const buyer = this.buyer(); @@ -38,7 +44,11 @@ export class ReturnDetailsHeaderComponent { }); name = computed(() => { - console.log({ buyer: this.buyer() }); + const buyer = this.buyer(); + if (!buyer) { + return ''; + } + const firstName = this.buyer()?.firstName; const lastName = this.buyer()?.lastName; const organisationName = this.buyer()?.organisation?.name; diff --git a/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.html b/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.html new file mode 100644 index 000000000..6ce7b6689 --- /dev/null +++ b/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.html @@ -0,0 +1,43 @@ + + + + + @if (receiptResource.isLoading()) { + + } @else if (receiptResource.value()) { + @let r = receiptResource.value()!; + + + + + + @for (item of r.items; track item.id; let last = $last) { + + } + } + + diff --git a/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.scss b/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.ts b/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.ts new file mode 100644 index 000000000..99b2c1abc --- /dev/null +++ b/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.ts @@ -0,0 +1,57 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + viewChild, +} from '@angular/core'; +import { ReturnDetailsOrderGroupComponent } from '../return-details-order-group/return-details-order-group.component'; +import { ReturnDetailsOrderGroupDataComponent } from '../return-details-order-group-data/return-details-order-group-data.component'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { isaActionPlus, isaActionMinus } from '@isa/icons'; +import { ReturnDetailsOrderGroupItemComponent } from '../return-details-order-group-item/return-details-order-group-item.component'; +import { ReceiptListItem, ReturnDetailsStore } from '@isa/oms/data-access'; +import { ExpandableDirective, ExpandableDirectives } from '@isa/ui/expandable'; +import { TextButtonComponent } from '@isa/ui/buttons'; +import { ProgressBarComponent } from '@isa/ui/progress-bar'; + +@Component({ + selector: 'oms-feature-return-details-lazy', + templateUrl: './return-details-lazy.component.html', + styleUrls: ['./return-details-lazy.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + ReturnDetailsOrderGroupComponent, + ReturnDetailsOrderGroupDataComponent, + ReturnDetailsOrderGroupItemComponent, + NgIcon, + ExpandableDirectives, + TextButtonComponent, + ProgressBarComponent, + ], + providers: [provideIcons({ isaActionPlus, isaActionMinus })], +}) +export class ReturnDetailsLazyComponent { + #store = inject(ReturnDetailsStore); + receipt = input.required(); + + receiptMap = this.#store.receiptsEntityMap; + + exbandable = viewChild(ExpandableDirective); + + receiptId = computed(() => { + const ex = this.exbandable(); + if (!ex) { + return; + } + + if (!ex.expanded()) { + return; + } + return this.receipt().id; + }); + + receiptResource = this.#store.receiptResource(this.receiptId); +} diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.html b/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.html index f3dbe6308..63bd2051c 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.html @@ -1,49 +1,51 @@ - - - Belegdatum: - - {{ (receipt().printedDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' }} - - - - Belegart: - - {{ receipt().receiptType | omsReceiptTypeTranslation }} - - - - Vorgang-ID: - - {{ receipt().order?.data?.orderNumber }} - - - - Bestelldatum: - - {{ - (receipt().order?.data?.orderDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' - }} - - - @if (receipt().buyer?.address; as address) { - - Anschrift: - -
{{ buyerName() }}
-
{{ address.street }} {{ address.streetNumber }}
-
{{ address.zipCode }} {{ address.city }}
-
-
- } +@let r = receipt(); - @if (receipt().shipping.address; as address) { +@if (r) { + - Lieferanschrift: + Belegdatum: -
{{ shippingName() }}
-
{{ address.street }} {{ address.streetNumber }}
-
{{ address.zipCode }} {{ address.city }}
+ {{ (r.printedDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' }}
- } -
+ + Belegart: + + {{ r.receiptType | omsReceiptTypeTranslation }} + + + + Vorgang-ID: + + {{ r.order?.data?.orderNumber }} + + + + Bestelldatum: + + {{ (r.order?.data?.orderDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' }} + + + @if (r.buyer?.address; as address) { + + Anschrift: + +
{{ buyerName() }}
+
{{ address.street }} {{ address.streetNumber }}
+
{{ address.zipCode }} {{ address.city }}
+
+
+ } + + @if (r.shipping?.address; as address) { + + Lieferanschrift: + +
{{ shippingName() }}
+
{{ address.street }} {{ address.streetNumber }}
+
{{ address.zipCode }} {{ address.city }}
+
+
+ } +
+} diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.ts b/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.ts index 92cc40b58..06d11c0de 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.ts @@ -1,9 +1,19 @@ import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; import { Receipt } from '@isa/oms/data-access'; import { ReceiptTypeTranslationPipe } from '@isa/oms/utils/translation'; import { ItemRowDataImports } from '@isa/ui/item-rows'; +export type ReceiptInput = Pick< + Receipt, + 'buyer' | 'shipping' | 'printedDate' | 'receiptType' | 'order' +>; + @Component({ selector: 'oms-feature-return-details-order-group-data', templateUrl: './return-details-order-group-data.component.html', @@ -13,15 +23,29 @@ import { ItemRowDataImports } from '@isa/ui/item-rows'; imports: [ItemRowDataImports, ReceiptTypeTranslationPipe, DatePipe], }) export class ReturnDetailsOrderGroupDataComponent { - receipt = input.required(); + receipt = input.required(); buyerName = computed(() => { const receipt = this.receipt(); - return [receipt.buyer?.firstName, receipt.buyer?.lastName].filter(Boolean).join(' '); + + if (!receipt) { + return ''; + } + + return [receipt.buyer?.firstName, receipt.buyer?.lastName] + .filter(Boolean) + .join(' '); }); shippingName = computed(() => { const receipt = this.receipt(); - return [receipt.shipping?.firstName, receipt.shipping?.lastName].filter(Boolean).join(' '); + + if (!receipt) { + return ''; + } + + return [receipt.shipping?.firstName, receipt.shipping?.lastName] + .filter(Boolean) + .join(' '); }); } diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html index 0623e3a30..967d10b79 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html @@ -1,9 +1,6 @@ @if (canReturnReceiptItem()) { @if (quantityDropdownValues().length > 1) { - + @for (quantity of quantityDropdownValues(); track quantity) { {{ quantity @@ -14,7 +11,7 @@ @for (kv of availableCategories; track kv.key) { @@ -22,17 +19,17 @@ } - @if (selectable()) { + @if (!canReturnResource.isLoading() && selectable()) { - } @else if (showProductCategoryDropdownLoading()) { + } @else if (canReturnResource.isLoading()) { (); - receiptId = input.required(); - #returnDetailsService = inject(ReturnDetailsService); - #returnDetailsStore = inject(ReturnDetailsStore); + #store = inject(ReturnDetailsStore); - #logger = logger(); + item = input.required(); - selected = model(false); + selected = this.#store.getItemSelected(this.item); - availableCategories: KeyValue[] = - this.#returnDetailsService.availableCategories(); + productCategoryChanged = signal(ProductCategory.Unknown); - quantity = computed(() => { - return this.item()?.quantity.quantity; + canReturnRequest = computed(() => { + const productCategory = this.productCategoryChanged(); + if (productCategory === ProductCategory.Unknown) { + return undefined; + } + return this.item(); }); - quantityDropdownValues = signal([]); + canReturnResource = this.#store.canReturnResource(this.canReturnRequest); - readonly showProductCategoryDropdownLoading = signal(false); + availableCategories = this.#returnDetailsService.availableCategories(); - getProductCategory = computed(() => { - return this.item()?.features?.['category'] || 'unknown'; + quantity = this.#store.getItemQuantity(this.item); + + quantityDropdownValues = computed(() => { + const itemQuantity = this.item().quantity.quantity; + return Array.from({ length: itemQuantity }, (_, i) => i + 1); }); - selectable = signal(false); + productCategory = this.#store.getProductCategory(this.item); - canReturn = output(); + selectable = this.#store.isSelectable(this.item); - canReturnReceiptItem = computed(() => - this.item()?.actions?.some( - (a) => a.key === 'canReturn' && coerceBooleanProperty(a.value), - ), - ); + canReturnReceiptItem = computed(() => canReturnReceiptItem(this.item())); - constructor() { - effect(() => { - const quantityDropdown = this.quantityDropdownValues(); - - untracked(() => { - if (quantityDropdown.length === 0) { - this.quantityDropdownValues.set( - Array.from({ length: this.quantity() }, (_, i) => i + 1), - ); - } - }); - }); - - effect(() => { - const productCategory = this.getProductCategory(); - const canReturnReceiptItem = this.canReturnReceiptItem(); - - const isSelectable = - canReturnReceiptItem && productCategory !== 'unknown'; - - if (!isSelectable) { - this.selectable.set(false); - } else { - this.selectable.set(true); - } - }); + setProductCategory(category: ProductCategory | undefined) { + if (!category) { + category = ProductCategory.Unknown; + } + this.#store.setProductCategory(this.item().id, category); + this.productCategoryChanged.set(category); } - async setProductCategory(category: string | undefined) { - const itemToUpdate = { - item: this.item(), - category: category || 'unknown', - }; + setQuantity(quantity: number | undefined) { + if (quantity === undefined) { + quantity = this.item().quantity.quantity; + } + this.#store.setQuantity(this.item().id, quantity); + } - try { - this.showProductCategoryDropdownLoading.set(true); - this.canReturn.emit(undefined); - this.selectable.set(false); - - const canReturn = - await this.#returnDetailsService.canReturn(itemToUpdate); - this.canReturn.emit(canReturn); - - this.changeProductCategory(category || 'unknown'); - this.showProductCategoryDropdownLoading.set(false); - } catch (error) { - this.#logger.error('Failed to setProductCategory', error, () => ({ - itemId: this.item().id, - category, - })); - this.canReturn.emit(undefined); - this.showProductCategoryDropdownLoading.set(false); + setSelected(selected: boolean) { + if (selected) { + this.#store.addSelectedItems([this.item().id]); + } else { + this.#store.removeSelectedItems([this.item().id]); } } - - changeProductCategory(category: string) { - this.#returnDetailsStore.updateProductCategoryForItem({ - receiptId: this.receiptId(), - itemId: this.item().id, - category, - }); - } - - changeProductQuantity(quantity: number) { - this.#returnDetailsStore.updateProductQuantityForItem({ - receiptId: this.receiptId(), - itemId: this.item().id, - quantity, - }); - } } diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html index 498f59489..b44029bce 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html @@ -61,14 +61,7 @@ {{ i.product.publicationDate | date: 'dd. MMM yyyy' }} - + diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.spec.ts b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.spec.ts deleted file mode 100644 index d75282559..000000000 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { byText } from '@ngneat/spectator'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; -import { MockDirective } from 'ng-mocks'; - -import { ReceiptItem, ReturnDetailsService } from '@isa/oms/data-access'; - -import { ReturnDetailsOrderGroupItemComponent } from './return-details-order-group-item.component'; -import { ProductImageDirective } from '@isa/shared/product-image'; -import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component'; - -// Helper function to create mock ReceiptItem data -const createMockItem = ( - ean: string, - canReturn: boolean, - name = 'Test Product', - category = 'BOOK', // Add default category that's not 'unknown' -): ReceiptItem => - ({ - id: 123, - quantity: { quantity: 1 }, - price: { - value: { value: 19.99, currency: 'EUR' }, - vat: { inPercent: 19 }, - }, - product: { - ean: ean, - name: name, - contributors: 'Test Author', - format: 'HC', - formatDetail: 'Hardcover', - manufacturer: 'Test Publisher', - publicationDate: '2024-01-01T00:00:00Z', - catalogProductNumber: '1234567890', - volume: '1', - }, - actions: [{ key: 'canReturn', value: String(canReturn) }], - features: { category: category }, // Add the features property with category - }) as ReceiptItem; - -describe('ReturnDetailsOrderGroupItemComponent', () => { - let spectator: Spectator; - const mockItemSelectable = createMockItem('1234567890123', true); - - const createComponent = createComponentFactory({ - component: ReturnDetailsOrderGroupItemComponent, - mocks: [ReturnDetailsService], - componentMocks: [ReturnDetailsOrderGroupItemControlsComponent], - // Spectator automatically stubs standalone dependencies like ItemRowComponent, CheckboxComponent etc. - // We don't need deep interaction, just verify the host component renders correctly. - // If specific interactions were needed, we could provide mocks or use overrideComponents. - overrideComponents: [ - [ - ReturnDetailsOrderGroupItemComponent, - { - remove: { imports: [ProductImageDirective] }, - add: { - imports: [MockDirective(ProductImageDirective)], - }, - }, - ], - ], - detectChanges: false, // Control initial detection manually - }); - - beforeEach(() => { - // Default setup with a selectable item - spectator = createComponent({ - props: { - item: mockItemSelectable, // Use signal for input - selected: false, // Use signal for model - receiptId: 123, - }, - }); - }); - - it('should create', () => { - // Arrange - spectator.detectChanges(); // Trigger initial render - - // Assert - expect(spectator.component).toBeTruthy(); - }); - - it('should display product details correctly', () => { - // Arrange - spectator.detectChanges(); - const item = mockItemSelectable; - - // Assert - expect(spectator.query(byText(item.product.contributors))).toExist(); - expect(spectator.query(`[data-what="product-name"]`)).toHaveText( - item.product.name, - ); - expect(spectator.query(`[data-what="product-price"]`)).toHaveText('€19.99'); // Assuming default locale formatting - expect( - spectator.query(byText(`inkl. ${item.price?.vat?.inPercent}% MwSt`)), - ).toExist(); - expect(spectator.query(`[data-what="product-info"]`)).toHaveText( - `${item.product.manufacturer} | ${item.product.ean}`, - ); - // Date formatting depends on locale, checking for year is safer - expect( - spectator.query(byText(/Jan 2024/)), // Adjust regex based on expected format/locale - ).toExist(); - expect(spectator.query(`img[data-what="product-image"]`)).toHaveAttribute( - 'data-which', - item.product.ean, - ); - }); -}); diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts index 96cf2a964..8c05cd010 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts @@ -1,16 +1,17 @@ -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { CurrencyPipe, DatePipe, LowerCasePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, + inject, input, - model, - signal, - WritableSignal, } from '@angular/core'; import { isaActionClose, ProductFormatIconGroup } from '@isa/icons'; -import { CanReturn, ReceiptItem } from '@isa/oms/data-access'; +import { + getReceiptItemAction, + ReceiptItem, + ReturnDetailsStore, +} from '@isa/oms/data-access'; import { ProductImageDirective } from '@isa/shared/product-image'; import { ItemRowComponent } from '@isa/ui/item-rows'; import { NgIconComponent, provideIcons } from '@ng-icons/core'; @@ -34,41 +35,41 @@ import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details- providers: [provideIcons({ ...ProductFormatIconGroup, isaActionClose })], }) export class ReturnDetailsOrderGroupItemComponent { + #store = inject(ReturnDetailsStore); + /** * The receipt item data to display. * Contains all information about a product including details, price, and return eligibility. */ item = input.required(); - /** - * The unique identifier of the receipt to which this item belongs. - * Used for making return eligibility checks against the backend API. - */ - receiptId = input.required(); - /** * Two-way binding for the selection state of the item. * Indicates whether the item is currently selected for return. */ - selected = model(false); + selected = computed(() => { + const selectedIds = this.#store.selectedItemIds(); + const item = this.item(); + return selectedIds.includes(item.id); + }); /** * Holds the return eligibility information from the API. * Contains both the eligibility result (boolean) and any message explaining the reason. * This signal may be undefined if the eligibility check hasn't completed yet. */ - canReturn: WritableSignal = signal(undefined); + canReturn = this.#store.getCanReturn(this.item); /** * Computes whether the item can be returned. * Prefers the endpoint result if available, otherwise checks the item's actions. */ canReturnReceiptItem = computed(() => { - const canReturn = this.canReturn()?.result; - const canReturnReceiptItem = this.item()?.actions?.some( - (a) => a.key === 'canReturn' && coerceBooleanProperty(a.value), + const returnableItems = this.#store.returnableItems(); + const item = this.item(); + return returnableItems.some( + (returnableItem) => returnableItem.id === item.id, ); - return canReturn ?? canReturnReceiptItem; // Endpoint Result (if existing) overrules item result }); /** @@ -76,10 +77,15 @@ export class ReturnDetailsOrderGroupItemComponent { * Prefers the endpoint message if available, otherwise uses the item's action description. */ canReturnMessage = computed(() => { + const item = this.item(); + const canReturnAction = getReceiptItemAction(item, 'canReturn'); + + if (canReturnAction?.description) { + return canReturnAction.description; + } + const canReturnMessage = this.canReturn()?.message; - const canReturnMessageOnReceiptItem = this.item()?.actions?.find( - (a) => a.key === 'canReturn', - )?.description; - return canReturnMessage ?? canReturnMessageOnReceiptItem; // Endpoint Message (if existing) overrules item message + + return canReturnMessage ?? ''; }); } diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group/return-details-order-group.component.html b/libs/oms/feature/return-details/src/lib/return-details-order-group/return-details-order-group.component.html index 3231e4674..b9099c474 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group/return-details-order-group.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group/return-details-order-group.component.html @@ -1,12 +1,13 @@ +@let r = receipt();
- {{ items().length }} Artikel + {{ itemCount() }} Artikel
- {{ receipt().printedDate | date }} + {{ r.printedDate | date }}
- {{ receipt().receiptNumber }} + {{ r.receiptNumber }}
@@ -16,7 +17,7 @@ uiTextButton color="strong" size="small" - (click)="selectOrUnselectAll()" + (click)="selectOrUnselectAll(); $event.stopPropagation()" > @if (allSelected()) { Alles abwählen @@ -25,4 +26,10 @@ } } + + @if (expandableTrigger?.expanded()) { + + } @else if (expandableTrigger) { + + }
diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group/return-details-order-group.component.ts b/libs/oms/feature/return-details/src/lib/return-details-order-group/return-details-order-group.component.ts index 2b2442b84..8424a1b8d 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group/return-details-order-group.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group/return-details-order-group.component.ts @@ -1,15 +1,29 @@ -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, + inject, input, - model, } from '@angular/core'; -import { Receipt, ReceiptItem } from '@isa/oms/data-access'; +import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons'; +import { + Receipt, + ReceiptListItem, + ReturnDetailsStore, +} from '@isa/oms/data-access'; import { TextButtonComponent } from '@isa/ui/buttons'; +import { + ExpandableDirective, + ExpandableTriggerDirective, + ExpandedDirective, +} from '@isa/ui/expandable'; import { ToolbarComponent } from '@isa/ui/toolbar'; +import { NgIcon, provideIcons } from '@ng-icons/core'; + +export type ReceiptInput = + | Pick + | Pick; @Component({ selector: 'oms-feature-return-details-order-group', @@ -17,37 +31,48 @@ import { ToolbarComponent } from '@isa/ui/toolbar'; styleUrls: ['./return-details-order-group.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [ToolbarComponent, TextButtonComponent, DatePipe], + imports: [ToolbarComponent, TextButtonComponent, DatePipe, NgIcon], + providers: [provideIcons({ isaActionChevronDown, isaActionChevronUp })], }) export class ReturnDetailsOrderGroupComponent { - receipt = input.required(); - items = input.required(); + #store = inject(ReturnDetailsStore); - selectedItems = model([]); - - selectableItems = computed(() => { - return this.items().filter( - (item) => - item.actions?.some( - (a) => a.key === 'canReturn' && coerceBooleanProperty(a.value), - ) && item?.features?.['category'] !== 'unknown', - ); + expandableTrigger = inject(ExpandableTriggerDirective, { + self: true, + optional: true, }); - selectOrUnselectAll() { - const selectedItems = this.selectedItems(); - const selectableItems = this.selectableItems(); - if (selectedItems.length === selectableItems.length) { - this.selectedItems.set([]); - return; - } + receipt = input.required(); - this.selectedItems.set(this.selectableItems()); + receiptId = computed(() => this.receipt().id); + + itemCount = computed(() => { + const receipt = this.receipt(); + if (typeof receipt.items === 'number') { + return receipt.items || 0; + } + return receipt.items?.length || 0; + }); + + items = this.#store.getItems(this.receiptId); + + selectableItems = this.#store.getSelectableItems(this.receiptId); + + selectOrUnselectAll() { + const selectableItems = this.selectableItems(); + + if (this.allSelected()) { + this.#store.removeSelectedItems(selectableItems.map((item) => item.id)); + } else { + this.#store.addSelectedItems(selectableItems.map((item) => item.id)); + } } allSelected = computed(() => { - const selectedItems = this.selectedItems(); + const selectedItemIds = this.#store.selectedItemIds(); const selectableItems = this.selectableItems(); - return selectedItems.length === selectableItems.length; + return selectableItems.every((item) => { + return selectedItemIds.includes(item.id); + }); }); } diff --git a/libs/oms/feature/return-details/src/lib/return-details-static/return-details-static.component.html b/libs/oms/feature/return-details/src/lib/return-details-static/return-details-static.component.html new file mode 100644 index 000000000..ed5000ef3 --- /dev/null +++ b/libs/oms/feature/return-details/src/lib/return-details-static/return-details-static.component.html @@ -0,0 +1,42 @@ +@let r = receipt(); + + + + + + + + + +@for (item of r.items; track item.id; let last = $last) { + +} diff --git a/libs/oms/feature/return-details/src/lib/return-details-static/return-details-static.component.scss b/libs/oms/feature/return-details/src/lib/return-details-static/return-details-static.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/libs/oms/feature/return-details/src/lib/return-details-static/return-details-static.component.ts b/libs/oms/feature/return-details/src/lib/return-details-static/return-details-static.component.ts new file mode 100644 index 000000000..a74d55bef --- /dev/null +++ b/libs/oms/feature/return-details/src/lib/return-details-static/return-details-static.component.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ReturnDetailsHeaderComponent } from '../return-details-header/return-details-header.component'; +import { ReturnDetailsOrderGroupComponent } from '../return-details-order-group/return-details-order-group.component'; +import { ReturnDetailsOrderGroupDataComponent } from '../return-details-order-group-data/return-details-order-group-data.component'; +import { ReturnDetailsDataComponent } from '../return-details-data/return-details-data.component'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { isaActionPlus, isaActionMinus } from '@isa/icons'; +import { ReturnDetailsOrderGroupItemComponent } from '../return-details-order-group-item/return-details-order-group-item.component'; +import { Receipt } from '@isa/oms/data-access'; +import { ExpandableDirectives } from '@isa/ui/expandable'; +import { TextButtonComponent } from '@isa/ui/buttons'; + +@Component({ + selector: 'oms-feature-return-details-static', + templateUrl: './return-details-static.component.html', + styleUrls: ['./return-details-static.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + ReturnDetailsHeaderComponent, + ReturnDetailsOrderGroupComponent, + ReturnDetailsOrderGroupDataComponent, + ReturnDetailsDataComponent, + ReturnDetailsOrderGroupItemComponent, + NgIcon, + ExpandableDirectives, + TextButtonComponent, + ], + providers: [provideIcons({ isaActionPlus, isaActionMinus })], +}) +export class ReturnDetailsStaticComponent { + receipt = input.required(); +} diff --git a/libs/oms/feature/return-details/src/lib/return-details.component.html b/libs/oms/feature/return-details/src/lib/return-details.component.html index 9b5f23125..d03394bed 100644 --- a/libs/oms/feature/return-details/src/lib/return-details.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details.component.html @@ -1,5 +1,3 @@ -@let receipt = receiptResult().data; -
-@if (receipt) { -
- - - @if (showMore()) { - - +
+ @if (receiptResource.value(); as r) { + + @if (customerReceiptsResource.isLoading()) { + } @else { - - + @for (receipt of customerReceiptsResource.value(); track receipt.id) { + @if (r.id !== receipt.id) { + + } + } } -
- - @for (item of receipt.items; track item.id; let last = $last) { - - } -
-} + } @else { + + } +
+ +
+ + +

Summary information

+
+ + + +

Detailed information

+
+
+ + ` +}) +export class MyComponent {} +``` + +### With Two-Way Binding + +```typescript +import { Component, signal } from '@angular/core'; +import { ExpandableDirectives } from '@isa/ui/expandable'; + +@Component({ + selector: 'app-my-component', + standalone: true, + imports: [...ExpandableDirectives], + template: ` +
+ + +
+ + Expanded content + + + Collapsed content + +
+
+ +

The section is currently: {{ isExpanded() ? 'Expanded' : 'Collapsed' }}

+ + ` +}) +export class MyComponent { + isExpanded = signal(false); +} +``` + +## API Reference + +### ExpandableDirective + +The main container directive that manages expanded/collapsed state. + +**Selector:** `[uiExpandable]` + +**Inputs:** +- `[(uiExpandable)]`: Two-way binding for the expanded state. + +**Methods:** +- `toggle()`: Toggles between expanded and collapsed states. + +### ExpandedDirective + +Structural directive that shows content only when expanded. + +**Selector:** `[uiExpanded]` + +### CollapsedDirective + +Structural directive that shows content only when collapsed. + +**Selector:** `[uiCollapsed]` + +### ExpandableTriggerDirective + +Adds toggle functionality and accessibility attributes to an element. + +**Selector:** `[uiExpandableTrigger]` + +**Inputs:** +- `uiExpandableTrigger`: String ID of the element being controlled (used for aria-controls). + +**Methods:** +- `toggle()`: Toggles the parent expandable's state. +- `expanded()`: Returns the current expanded state (signal). + +**Exported as:** `uiExpandableTrigger` + +## Accessibility + +These directives automatically add the following accessibility features: +- `role="button"` on the trigger element +- `aria-expanded` with the current state on the trigger element +- `aria-controls` linking the trigger to the content section (requires matching the ID) + +## Example: Real-world Usage in Return Details Component + +```html +
+ + +
+ + + + + + + +
+
+``` + +## Running unit tests + +Run `nx test ui-expandable` to execute the unit tests. diff --git a/libs/ui/expandable/eslint.config.mjs b/libs/ui/expandable/eslint.config.mjs new file mode 100644 index 000000000..c68787af3 --- /dev/null +++ b/libs/ui/expandable/eslint.config.mjs @@ -0,0 +1,34 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../../eslint.config.mjs'; + +export default [ + ...baseConfig, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'ui', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'ui', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/libs/ui/expandable/jest.config.ts b/libs/ui/expandable/jest.config.ts new file mode 100644 index 000000000..55258a0a6 --- /dev/null +++ b/libs/ui/expandable/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'ui-expandable', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/ui/expandable', + 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/ui/expandable/project.json b/libs/ui/expandable/project.json new file mode 100644 index 000000000..2efd9a816 --- /dev/null +++ b/libs/ui/expandable/project.json @@ -0,0 +1,20 @@ +{ + "name": "ui-expandable", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ui/expandable/src", + "prefix": "ui", + "projectType": "library", + "tags": [], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ui/expandable/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/ui/expandable/src/index.ts b/libs/ui/expandable/src/index.ts new file mode 100644 index 000000000..6089c492b --- /dev/null +++ b/libs/ui/expandable/src/index.ts @@ -0,0 +1,5 @@ +export * from './lib/expandable.directive'; +export * from './lib/expanded.directive'; +export * from './lib/collapsed.directive'; +export * from './lib/expandable-trigger.directive'; +export * from './lib/directives'; diff --git a/libs/ui/expandable/src/lib/collapsed.directive.ts b/libs/ui/expandable/src/lib/collapsed.directive.ts new file mode 100644 index 000000000..306df2450 --- /dev/null +++ b/libs/ui/expandable/src/lib/collapsed.directive.ts @@ -0,0 +1,59 @@ +import { + Directive, + effect, + inject, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { ExpandableDirective } from './expandable.directive'; + +/** + * Structural directive that conditionally renders content when the + * parent ExpandableDirective is in the collapsed state. + * + * This directive must be used within an element that has the uiExpandable + * directive applied. It shows its content only when the parent expandable + * is in the collapsed state. + * + * @example + * ```html + *
+ * + * Content shown when collapsed + * + *
+ * ``` + */ +@Directive({ selector: '[uiCollapsed]' }) +export class CollapsedDirective { + /** + * Reference to the parent ExpandableDirective that controls state + * @private + */ + #expandable = inject(ExpandableDirective, { host: true }); + + /** + * Template to be rendered + * @private + */ + #templateRef = inject(TemplateRef); + + /** + * Container for rendering the view + * @private + */ + #viewContainer = inject(ViewContainerRef); + + /** + * Effect that reacts to changes in the expanded state. + * Creates or clears the view based on the expandable state. + * @private + */ + render = effect(() => { + if (this.#expandable.expanded()) { + this.#viewContainer.clear(); + } else { + this.#viewContainer.createEmbeddedView(this.#templateRef); + } + }); +} diff --git a/libs/ui/expandable/src/lib/directives.ts b/libs/ui/expandable/src/lib/directives.ts new file mode 100644 index 000000000..75ea71b03 --- /dev/null +++ b/libs/ui/expandable/src/lib/directives.ts @@ -0,0 +1,22 @@ +import { ExpandableDirective } from './expandable.directive'; +import { ExpandedDirective } from './expanded.directive'; +import { CollapsedDirective } from './collapsed.directive'; +import { ExpandableTriggerDirective } from './expandable-trigger.directive'; + +/** + * Convenience array containing all the directives required for the Expandable system. + * + * Can be used to import all directives at once in a component or module: + * ```typescript + * @Component({ + * imports: [...ExpandableDirectives] + * }) + * ``` + */ +export const ExpandableDirectives = [ + ExpandableDirective, + ExpandedDirective, + CollapsedDirective, + ExpandableTriggerDirective, +]; + diff --git a/libs/ui/expandable/src/lib/expandable-trigger.directive.ts b/libs/ui/expandable/src/lib/expandable-trigger.directive.ts new file mode 100644 index 000000000..71874a443 --- /dev/null +++ b/libs/ui/expandable/src/lib/expandable-trigger.directive.ts @@ -0,0 +1,52 @@ +import { Directive, inject, input } from '@angular/core'; +import { ExpandableDirective } from './expandable.directive'; + +/** + * Directive that turns an element into a toggle trigger for an ExpandableDirective. + * + * This directive must be used within an element that has the uiExpandable + * directive applied. It adds click handling and proper accessibility attributes + * to control the expanded/collapsed state. + * + * @example + * ```html + *
+ * + *
+ * ``` + */ +@Directive({ + selector: '[uiExpandableTrigger]', + exportAs: 'uiExpandableTrigger', + host: { + '(click)': 'toggle()', + '[attr.aria-expanded]': 'expanded()', + 'role': 'button', + }, +}) +export class ExpandableTriggerDirective { + /** + * Reference to the parent ExpandableDirective that controls state + * @private + */ + #expandable = inject(ExpandableDirective, { host: true }); + + /** + * Toggles the expanded/collapsed state of the parent expandable section. + */ + toggle() { + this.#expandable.toggle(); + } + + /** + * Returns the current expanded state as a signal. + * Useful for conditionally showing content in the trigger based on state. + * + * @returns A signal with the current expanded state (boolean) + */ + expanded() { + return this.#expandable.expanded(); + } +} diff --git a/libs/ui/expandable/src/lib/expandable.directive.ts b/libs/ui/expandable/src/lib/expandable.directive.ts new file mode 100644 index 000000000..52d3a4e02 --- /dev/null +++ b/libs/ui/expandable/src/lib/expandable.directive.ts @@ -0,0 +1,40 @@ +import { Directive, model } from '@angular/core'; + +/** + * Core directive that manages the expanded/collapsed state of a section. + * + * This directive serves as the foundation for the expandable system, providing state management + * and a toggle function. It uses Angular's model input API for two-way binding. + * + * @example + * ```html + *
+ * + *
+ * ``` + */ +@Directive({ selector: '[uiExpandable]', exportAs: 'uiExpandable' }) +export class ExpandableDirective { + /** + * Signal model that tracks and manages the expanded state. + * Can be bound to with [(uiExpanded)] for two-way binding. + * + * @default false - Initially collapsed + */ + expanded = model(false, { alias: 'uiExpanded' }); + + /** + * Toggles the expanded/collapsed state. + */ + toggle() { + this.expanded.update((expanded) => !expanded); + } + + expand() { + this.expanded.set(true); + } + + collapse() { + this.expanded.set(false); + } +} diff --git a/libs/ui/expandable/src/lib/expanded.directive.ts b/libs/ui/expandable/src/lib/expanded.directive.ts new file mode 100644 index 000000000..a2e940435 --- /dev/null +++ b/libs/ui/expandable/src/lib/expanded.directive.ts @@ -0,0 +1,59 @@ +import { + Directive, + effect, + inject, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { ExpandableDirective } from './expandable.directive'; + +/** + * Structural directive that conditionally renders content when the + * parent ExpandableDirective is in the expanded state. + * + * This directive must be used within an element that has the uiExpandable + * directive applied. It shows its content only when the parent expandable + * is in the expanded state. + * + * @example + * ```html + *
+ * + * Content shown when expanded + * + *
+ * ``` + */ +@Directive({ selector: '[uiExpanded]' }) +export class ExpandedDirective { + /** + * Reference to the parent ExpandableDirective that controls state + * @private + */ + #expandable = inject(ExpandableDirective, { host: true }); + + /** + * Template to be rendered + * @private + */ + #templateRef = inject(TemplateRef); + + /** + * Container for rendering the view + * @private + */ + #viewContainer = inject(ViewContainerRef); + + /** + * Effect that reacts to changes in the expanded state. + * Creates or clears the view based on the expandable state. + * @private + */ + render = effect(() => { + if (!this.#expandable.expanded()) { + this.#viewContainer.clear(); + } else { + this.#viewContainer.createEmbeddedView(this.#templateRef); + } + }); +} diff --git a/libs/ui/expandable/src/test-setup.ts b/libs/ui/expandable/src/test-setup.ts new file mode 100644 index 000000000..ea414013f --- /dev/null +++ b/libs/ui/expandable/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/ui/expandable/tsconfig.json b/libs/ui/expandable/tsconfig.json new file mode 100644 index 000000000..fde35eab0 --- /dev/null +++ b/libs/ui/expandable/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es2022", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/ui/expandable/tsconfig.lib.json b/libs/ui/expandable/tsconfig.lib.json new file mode 100644 index 000000000..9b49be758 --- /dev/null +++ b/libs/ui/expandable/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/ui/expandable/tsconfig.spec.json b/libs/ui/expandable/tsconfig.spec.json new file mode 100644 index 000000000..f858ef78c --- /dev/null +++ b/libs/ui/expandable/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 7ff97312e..38cb11cb7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -76,6 +76,7 @@ "@isa/ui/datepicker": ["libs/ui/datepicker/src/index.ts"], "@isa/ui/dialog": ["libs/ui/dialog/src/index.ts"], "@isa/ui/empty-state": ["libs/ui/empty-state/src/index.ts"], + "@isa/ui/expandable": ["libs/ui/expandable/src/index.ts"], "@isa/ui/input-controls": ["libs/ui/input-controls/src/index.ts"], "@isa/ui/item-rows": ["libs/ui/item-rows/src/index.ts"], "@isa/ui/layout": ["libs/ui/layout/src/index.ts"],