Merged PR 1919: feat(remission): add impediment management and UI enhancements for remission...

feat(remission): add impediment management and UI enhancements for remission list

Implement comprehensive impediment handling for return items and suggestions
with enhanced user interface components and improved data access layer.

Key additions:
- Add impediment update schema and validation for return items
- Implement RemissionReturnReceiptService with full CRUD operations
- Create RemissionListItemComponent with actions and selection capabilities
- Add ProductInfoComponent with responsive layout and labeling
- Enhance UI Dialog system with improved injection patterns and testing
- Add comprehensive test coverage for all new components and services
- Implement proper data attributes for E2E testing support

Technical improvements:
- Follow SOLID principles with clear separation of concerns
- Use OnPush change detection strategy for optimal performance
- Implement proper TypeScript typing with Zod schema validation
- Add comprehensive JSDoc documentation for all public APIs
- Use modern Angular signals and computed properties for state management

Refs: #5275, #5038
This commit is contained in:
Nino Righi
2025-08-14 14:05:01 +00:00
committed by Andreas Schickinger
parent 0740273dbc
commit 514715589b
29 changed files with 832 additions and 116 deletions

View File

@@ -1,5 +1,5 @@
.ui-dialog {
@apply bg-isa-white p-8 grid gap-8 items-start rounded-[2rem] grid-flow-row text-isa-neutral-900 relative;
@apply bg-isa-white p-8 grid gap-4 items-start rounded-[2rem] grid-flow-row text-isa-neutral-900 relative;
@apply max-h-[90vh] max-w-[90vw] overflow-hidden;
grid-template-rows: auto 1fr;
@@ -13,4 +13,8 @@
@apply overflow-y-auto overflow-x-hidden;
@apply min-h-0;
}
.ui-dialog-close-cta {
@apply flex justify-end items-center;
}
}

View File

@@ -8,12 +8,17 @@ import { DialogComponent } from './dialog.component';
@Component({
selector: 'ui-test-dialog-content',
template: '<div>Test dialog content</div>',
standalone: true,
})
class TestDialogContentComponent extends DialogContentDirective<unknown, unknown> {}
class TestDialogContentComponent extends DialogContentDirective<
unknown,
unknown
> {}
describe('DialogContentDirective', () => {
let spectator: Spectator<TestDialogContentComponent>;
const mockDialogRef = { close: jest.fn() };
const mockDialog = { close: jest.fn() };
const mockData = { message: 'Test message' };
const createComponent = createComponentFactory({
@@ -21,7 +26,7 @@ describe('DialogContentDirective', () => {
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: mockData },
{ provide: DialogComponent, useValue: {} }
{ provide: DialogComponent, useValue: mockDialog },
],
});
@@ -40,12 +45,33 @@ describe('DialogContentDirective', () => {
it('should call dialogRef.close with provided result when close method is called', () => {
const result = { success: true };
// Act
spectator.component.close(result);
// Assert
expect(mockDialogRef.close).toHaveBeenCalledWith(result);
expect(mockDialogRef.close).toHaveBeenCalledTimes(1);
});
it('should have access to DialogComponent instance', () => {
expect(spectator.component.dialog).toBe(mockDialog);
});
it('should have access to DialogRef instance', () => {
expect(spectator.component.dialogRef).toBe(mockDialogRef);
});
it('should apply ui-dialog-content class to host element', () => {
expect(spectator.element).toHaveClass('ui-dialog-content');
});
it('should call dialogRef.close without parameters when close is called without result', () => {
// Act
spectator.component.close(undefined as any);
// Assert
expect(mockDialogRef.close).toHaveBeenCalledWith(undefined);
expect(mockDialogRef.close).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,3 +1,9 @@
@if (displayCloseButton()) {
<button class="ui-dialog-close-cta" type="button" (click)="close()">
<ng-icon size="1.75rem" name="isaActionClose"></ng-icon>
</button>
}
@if (title()) {
<h2 class="ui-dialog-title" data-what="title">
{{ title() }}

View File

@@ -38,6 +38,7 @@ describe('DialogComponent', () => {
beforeEach(() => {
spectator = createComponent();
jest.clearAllMocks();
});
it('should create the component', () => {
@@ -56,4 +57,13 @@ describe('DialogComponent', () => {
it('should apply the ui-dialog class to the host element', () => {
expect(spectator.element).toHaveClass('ui-dialog');
});
it('should call dialogRef.close when close method is called', () => {
// Act
spectator.component.close();
// Assert
expect(mockDialogRef.close).toHaveBeenCalledWith();
expect(mockDialogRef.close).toHaveBeenCalledTimes(1);
});
});

View File

@@ -6,9 +6,17 @@ import {
signal,
} from '@angular/core';
import { DialogContentDirective } from './dialog-content.directive';
import { DIALOG_CLASS_LIST, DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
import {
DIALOG_CLASS_LIST,
DIALOG_CONTENT,
DIALOG_TITLE,
DISPLAY_DIALOG_CLOSE,
} from './tokens';
import { ComponentType } from '@angular/cdk/portal';
import { NgComponentOutlet } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
import { DialogRef } from '@angular/cdk/dialog';
/**
* Base dialog component that serves as a container for dialog content
@@ -22,10 +30,11 @@ import { NgComponentOutlet } from '@angular/common';
selector: 'ui-dialog',
templateUrl: './dialog.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgComponentOutlet],
imports: [NgComponentOutlet, NgIcon],
host: {
'[class]': 'classes()',
},
providers: [provideIcons({ isaActionClose })],
})
export class DialogComponent<D, R, C extends DialogContentDirective<D, R>> {
/** The title to display at the top of the dialog */
@@ -34,6 +43,19 @@ export class DialogComponent<D, R, C extends DialogContentDirective<D, R>> {
/** The component type to instantiate as the dialog content */
readonly component = inject(DIALOG_CONTENT) as ComponentType<C>;
/** Reference to the dialog instance for closing the dialog */
private readonly dialogRef = inject(DialogRef<R, DialogComponent<D, R, C>>);
/**
* Signal that determines whether the close button should be displayed
* This is controlled by an injection token, allowing for flexible configuration
*/
readonly displayCloseButton = signal(
inject(DISPLAY_DIALOG_CLOSE, {
optional: true,
}),
);
/** Additional CSS classes provided via injection */
private readonly classList =
inject(DIALOG_CLASS_LIST, { optional: true }) ?? [];
@@ -47,4 +69,12 @@ export class DialogComponent<D, R, C extends DialogContentDirective<D, R>> {
classes = computed(() => {
return ['ui-dialog', ...this.classList];
});
/**
* Closes the dialog without returning any result
* This is typically called when the user clicks the close button (X)
*/
close(): void {
this.dialogRef.close();
}
}

View File

@@ -10,7 +10,7 @@ import {
} from './injects';
import { MessageDialogComponent } from './message-dialog/message-dialog.component';
import { DialogComponent } from './dialog.component';
import { DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
import { DIALOG_CONTENT, DIALOG_TITLE, DISPLAY_DIALOG_CLOSE } from './tokens';
import { Component } from '@angular/core';
import { DialogContentDirective } from './dialog-content.directive';
import { TextInputDialogComponent } from './text-input-dialog/text-input-dialog.component';
@@ -143,6 +143,57 @@ describe('Dialog Injects', () => {
const injector = callOptions.injector;
expect(injector.get(DIALOG_CONTENT)).toBe(componentType);
});
it('should set displayClose to false by default', () => {
// Arrange
const componentType = TestDialogContentComponent;
const data = { message: 'Test Message' };
// Act
const openDialog = TestBed.runInInjectionContext(() =>
injectDialog(componentType),
);
openDialog({ data });
// Assert
const callOptions = mockDialogOpen.mock.calls[0][1];
const injector = callOptions.injector;
expect(injector.get(DISPLAY_DIALOG_CLOSE)).toBe(false);
});
it('should use provided displayClose option from inject options', () => {
// Arrange
const componentType = TestDialogContentComponent;
const data = { message: 'Test Message' };
// Act
const openDialog = TestBed.runInInjectionContext(() =>
injectDialog(componentType, { displayClose: true }),
);
openDialog({ data });
// Assert
const callOptions = mockDialogOpen.mock.calls[0][1];
const injector = callOptions.injector;
expect(injector.get(DISPLAY_DIALOG_CLOSE)).toBe(true);
});
it('should use provided displayClose option from open options and override inject options', () => {
// Arrange
const componentType = TestDialogContentComponent;
const data = { message: 'Test Message' };
// Act
const openDialog = TestBed.runInInjectionContext(() =>
injectDialog(componentType, { displayClose: true }),
);
openDialog({ data, displayClose: false });
// Assert
const callOptions = mockDialogOpen.mock.calls[0][1];
const injector = callOptions.injector;
expect(injector.get(DISPLAY_DIALOG_CLOSE)).toBe(false);
});
});
describe('injectMessageDialog', () => {

View File

@@ -4,7 +4,12 @@ import { ComponentType } from '@angular/cdk/portal';
import { inject, Injector } from '@angular/core';
import { DialogContentDirective } from './dialog-content.directive';
import { DialogComponent } from './dialog.component';
import { DIALOG_CLASS_LIST, DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
import {
DIALOG_CLASS_LIST,
DIALOG_CONTENT,
DIALOG_TITLE,
DISPLAY_DIALOG_CLOSE,
} from './tokens';
import { MessageDialogComponent } from './message-dialog/message-dialog.component';
import { TextInputDialogComponent } from './text-input-dialog/text-input-dialog.component';
import { NumberInputDialogComponent } from './number-input-dialog/number-input-dialog.component';
@@ -21,6 +26,9 @@ export interface InjectDialogOptions {
/** Optional title override for the dialog */
title?: string;
/** Optional flag to control whether the close button is displayed */
displayClose?: boolean;
/** Optional additional CSS classes to apply to the dialog */
classList?: string[];
@@ -89,6 +97,11 @@ export function injectDialog<C extends DialogContentDirective<any, any>>(
provide: DIALOG_TITLE,
useValue: openOptions?.title ?? injectOptions?.title,
},
{
provide: DISPLAY_DIALOG_CLOSE,
useValue:
openOptions?.displayClose ?? injectOptions?.displayClose ?? false, // #5275 Default false -> Bei true müssten ALLE eingesetzten Dialoge überprüft werden, da bei close ein undefined emitted wird und mit diesem Wert evtl. bereits Logik verbunden ist
},
{
provide: DIALOG_CLASS_LIST,
useValue: openOptions?.classList ?? injectOptions?.classList ?? [],

View File

@@ -9,6 +9,15 @@ export const DIALOG_TITLE = new InjectionToken<string | undefined>(
'DIALOG_TITLE',
);
/**
* Injection token for controlling the display of the close button in the dialog
* If set to true, a close button will be displayed; if false, it will not be shown
* If undefined, the default behavior is used (usually true)
*/
export const DISPLAY_DIALOG_CLOSE = new InjectionToken<boolean | undefined>(
'DISPLAY_DIALOG_CLOSE',
);
/**
* Injection token for providing the dialog content component
* Used internally by the dialog system to instantiate the correct content component

View File

@@ -1,11 +1,31 @@
.ui-label {
@apply flex items-center justify-center px-3 py-[0.125rem] min-w-14 rounded-[3.125rem] isa-text-caption-regular text-ellipsis whitespace-nowrap;
@apply flex items-center justify-center text-ellipsis whitespace-nowrap;
}
.ui-label__primary {
.ui-label__tag {
@apply px-3 py-[0.125rem] min-w-14 rounded-[3.125rem] isa-text-caption-regular;
}
.ui-label__tag-priority-high {
@apply bg-isa-neutral-700 text-isa-neutral-400;
}
.ui-label__secondary {
.ui-label__tag-priority-low {
@apply bg-isa-neutral-300 text-isa-neutral-600;
}
.ui-label__notice {
@apply p-2 min-w-48 rounded-lg isa-text-body-2-bold text-isa-neutral-900;
}
.ui-label__notice-priority-high {
@apply bg-isa-secondary-100;
}
.ui-label__notice-priority-medium {
@apply bg-isa-neutral-100;
}
.ui-label__notice-priority-low {
@apply bg-transparent;
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LabelComponent } from './label.component';
import { By } from '@angular/platform-browser';
import { LabelPriority, Labeltype } from './types';
describe('LabelComponent', () => {
let component: LabelComponent;
@@ -22,61 +22,127 @@ describe('LabelComponent', () => {
expect(component).toBeTruthy();
});
it('should have default appearance as primary', () => {
expect(component.appearance()).toBe('primary');
it('should have default type as tag', () => {
expect(component.type()).toBe(Labeltype.Tag);
});
it('should accept secondary appearance', () => {
fixture.componentRef.setInput('appearance', 'secondary');
it('should have default priority as high', () => {
expect(component.priority()).toBe(LabelPriority.High);
});
it('should accept notice type', () => {
fixture.componentRef.setInput('type', Labeltype.Notice);
fixture.detectChanges();
expect(component.appearance()).toBe('secondary');
expect(component.type()).toBe(Labeltype.Notice);
});
it('should have correct CSS classes for primary appearance', () => {
expect(component.appearanceClass()).toBe('ui-label__primary');
});
it('should have correct CSS classes for secondary appearance', () => {
fixture.componentRef.setInput('appearance', 'secondary');
it('should accept different priority levels', () => {
fixture.componentRef.setInput('priority', LabelPriority.Medium);
fixture.detectChanges();
expect(component.appearanceClass()).toBe('ui-label__secondary');
expect(component.priority()).toBe(LabelPriority.Medium);
fixture.componentRef.setInput('priority', LabelPriority.Low);
fixture.detectChanges();
expect(component.priority()).toBe(LabelPriority.Low);
});
it('should have correct CSS classes for default type and priority', () => {
expect(component.typeClass()).toBe('ui-label__tag');
expect(component.priorityClass()).toBe('ui-label__tag-priority-high');
});
it('should have correct CSS classes for notice type', () => {
fixture.componentRef.setInput('type', Labeltype.Notice);
fixture.detectChanges();
expect(component.typeClass()).toBe('ui-label__notice');
expect(component.priorityClass()).toBe('ui-label__notice-priority-high');
});
it('should have correct CSS classes for different priorities', () => {
fixture.componentRef.setInput('priority', LabelPriority.Medium);
fixture.detectChanges();
expect(component.priorityClass()).toBe('ui-label__tag-priority-medium');
fixture.componentRef.setInput('priority', LabelPriority.Low);
fixture.detectChanges();
expect(component.priorityClass()).toBe('ui-label__tag-priority-low');
});
it('should set host classes correctly', () => {
const hostElement = fixture.debugElement.nativeElement;
expect(hostElement.classList.contains('ui-label')).toBe(true);
expect(hostElement.classList.contains('ui-label__primary')).toBe(true);
expect(hostElement.classList.contains('ui-label__tag')).toBe(true);
expect(
hostElement.classList.contains('ui-label__tag-priority-high'),
).toBe(true);
});
});
describe('Template Rendering', () => {
it('should display content in default primary appearance', () => {
it('should display content with default type and priority classes', () => {
const labelElement = fixture.debugElement.nativeElement;
expect(labelElement.classList.contains('ui-label')).toBe(true);
expect(labelElement.classList.contains('ui-label__primary')).toBe(true);
expect(labelElement.classList.contains('ui-label__tag')).toBe(true);
expect(
labelElement.classList.contains('ui-label__tag-priority-high'),
).toBe(true);
});
it('should display content in secondary appearance', () => {
fixture.componentRef.setInput('appearance', 'secondary');
it('should display content with notice type', () => {
fixture.componentRef.setInput('type', Labeltype.Notice);
fixture.detectChanges();
const labelElement = fixture.debugElement.nativeElement;
expect(labelElement.classList.contains('ui-label')).toBe(true);
expect(labelElement.classList.contains('ui-label__secondary')).toBe(true);
expect(labelElement.classList.contains('ui-label__notice')).toBe(true);
expect(
labelElement.classList.contains('ui-label__notice-priority-high'),
).toBe(true);
});
it('should display content with different priority levels', () => {
fixture.componentRef.setInput('priority', LabelPriority.Low);
fixture.detectChanges();
const labelElement = fixture.debugElement.nativeElement;
expect(labelElement.classList.contains('ui-label')).toBe(true);
expect(labelElement.classList.contains('ui-label__tag')).toBe(true);
expect(
labelElement.classList.contains('ui-label__tag-priority-low'),
).toBe(true);
});
});
describe('Input Validation', () => {
it('should handle appearance input changes', () => {
fixture.componentRef.setInput('appearance', 'primary');
it('should handle type input changes', () => {
fixture.componentRef.setInput('type', Labeltype.Tag);
fixture.detectChanges();
expect(component.appearance()).toBe('primary');
expect(component.appearanceClass()).toBe('ui-label__primary');
expect(component.type()).toBe(Labeltype.Tag);
expect(component.typeClass()).toBe('ui-label__tag');
expect(component.priorityClass()).toBe('ui-label__tag-priority-high');
fixture.componentRef.setInput('appearance', 'secondary');
fixture.componentRef.setInput('type', Labeltype.Notice);
fixture.detectChanges();
expect(component.appearance()).toBe('secondary');
expect(component.appearanceClass()).toBe('ui-label__secondary');
expect(component.type()).toBe(Labeltype.Notice);
expect(component.typeClass()).toBe('ui-label__notice');
expect(component.priorityClass()).toBe('ui-label__notice-priority-high');
});
it('should handle priority input changes', () => {
fixture.componentRef.setInput('priority', LabelPriority.High);
fixture.detectChanges();
expect(component.priority()).toBe(LabelPriority.High);
expect(component.priorityClass()).toBe('ui-label__tag-priority-high');
fixture.componentRef.setInput('priority', LabelPriority.Medium);
fixture.detectChanges();
expect(component.priority()).toBe(LabelPriority.Medium);
expect(component.priorityClass()).toBe('ui-label__tag-priority-medium');
fixture.componentRef.setInput('priority', LabelPriority.Low);
fixture.detectChanges();
expect(component.priority()).toBe(LabelPriority.Low);
expect(component.priorityClass()).toBe('ui-label__tag-priority-low');
});
});
@@ -87,19 +153,69 @@ describe('LabelComponent', () => {
expect(hostElement.classList.length).toBeGreaterThan(0);
});
it('should update classes when appearance changes', () => {
it('should update classes when type changes', () => {
const hostElement = fixture.debugElement.nativeElement;
// Initial state
expect(hostElement.classList.contains('ui-label__primary')).toBe(true);
expect(hostElement.classList.contains('ui-label__secondary')).toBe(false);
expect(hostElement.classList.contains('ui-label__tag')).toBe(true);
expect(hostElement.classList.contains('ui-label__notice')).toBe(false);
expect(
hostElement.classList.contains('ui-label__tag-priority-high'),
).toBe(true);
// Change to secondary
fixture.componentRef.setInput('appearance', 'secondary');
// Change to notice
fixture.componentRef.setInput('type', Labeltype.Notice);
fixture.detectChanges();
expect(hostElement.classList.contains('ui-label__primary')).toBe(false);
expect(hostElement.classList.contains('ui-label__secondary')).toBe(true);
expect(hostElement.classList.contains('ui-label__tag')).toBe(false);
expect(hostElement.classList.contains('ui-label__notice')).toBe(true);
expect(
hostElement.classList.contains('ui-label__tag-priority-high'),
).toBe(false);
expect(
hostElement.classList.contains('ui-label__notice-priority-high'),
).toBe(true);
});
it('should update classes when priority changes', () => {
const hostElement = fixture.debugElement.nativeElement;
// Initial state
expect(
hostElement.classList.contains('ui-label__tag-priority-high'),
).toBe(true);
expect(
hostElement.classList.contains('ui-label__tag-priority-medium'),
).toBe(false);
// Change to medium priority
fixture.componentRef.setInput('priority', LabelPriority.Medium);
fixture.detectChanges();
expect(
hostElement.classList.contains('ui-label__tag-priority-high'),
).toBe(false);
expect(
hostElement.classList.contains('ui-label__tag-priority-medium'),
).toBe(true);
});
it('should maintain both type and priority classes simultaneously', () => {
const hostElement = fixture.debugElement.nativeElement;
fixture.componentRef.setInput('type', Labeltype.Notice);
fixture.componentRef.setInput('priority', LabelPriority.Low);
fixture.detectChanges();
expect(hostElement.classList.contains('ui-label')).toBe(true);
expect(hostElement.classList.contains('ui-label__notice')).toBe(true);
expect(
hostElement.classList.contains('ui-label__notice-priority-low'),
).toBe(true);
expect(hostElement.classList.contains('ui-label__tag')).toBe(false);
expect(
hostElement.classList.contains('ui-label__tag-priority-high'),
).toBe(false);
});
});
});

View File

@@ -6,16 +6,11 @@ import {
ViewEncapsulation,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { LabelAppearance } from './types';
import { LabelPriority, Labeltype } from './types';
/**
* A simple label component that can be used to display text with different appearances.
* It supports primary and secondary appearances.
* Example usage:
* ```html
* <ui-label appearance="primary">Primary Label</ui-label>
* <ui-label appearance="secondary">Secondary Label</ui-label>
* ```
* A component that displays a label with a specific type and priority.
* The label can be used to indicate tags or notices with different priorities.
*/
@Component({
selector: 'ui-label',
@@ -24,13 +19,21 @@ import { LabelAppearance } from './types';
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '["ui-label", appearanceClass()]',
'[class]': '["ui-label", typeClass(), priorityClass()]',
},
})
export class LabelComponent {
/** The appearance of the label. */
appearance = input<LabelAppearance>('primary');
/** The type of the label. */
type = input<Labeltype>(Labeltype.Tag);
/** A computed CSS class based on the current appearance. */
appearanceClass = computed(() => `ui-label__${this.appearance()}`);
/** A computed CSS class based on the current type. */
typeClass = computed(() => `ui-label__${this.type()}`);
/** The priority of the label. */
priority = input<LabelPriority>(LabelPriority.High);
/** A computed CSS class based on the current priority and typeClass. */
priorityClass = computed(
() => `${this.typeClass()}-priority-${this.priority()}`,
);
}

View File

@@ -1,7 +1,14 @@
export const LabelAppearance = {
Primary: 'primary',
Secondary: 'secondary',
export const Labeltype = {
Tag: 'tag',
Notice: 'notice',
} as const;
export type LabelAppearance =
(typeof LabelAppearance)[keyof typeof LabelAppearance];
export type Labeltype = (typeof Labeltype)[keyof typeof Labeltype];
export const LabelPriority = {
High: 'high',
Medium: 'medium',
Low: 'low',
} as const;
export type LabelPriority = (typeof LabelPriority)[keyof typeof LabelPriority];