- Add new reward-order-confirmation feature library with components and store - Implement checkout completion orchestrator service for order finalization - Migrate checkout/oms/crm models to Zod schemas for better type safety - Add order creation facade and display order schemas - Update shopping cart facade with order completion flow - Add comprehensive tests for shopping cart facade - Update routing to include order confirmation page
@isa/oms/feature/return-review
A comprehensive Angular feature library for reviewing completed return processes in the Order Management System (OMS). Provides a confirmation interface for successful returns with task review capabilities and receipt printing functionality.
Overview
The Return Review feature library implements the final step in the return process workflow, displaying a success confirmation and allowing users to review all tasks (completed and pending) associated with the return. It provides a read-only task list view with the ability to reprint return confirmation receipts, serving as both a completion confirmation and a reference point for return operations.
Table of Contents
- Features
- Quick Start
- Core Concepts
- Component API Reference
- Usage Examples
- Routing and Navigation
- Architecture Notes
- Dependencies
- Testing
- Best Practices
Features
- Success Confirmation - Clear visual confirmation of successful return completion
- Receipt Reprinting - One-click reprint of return confirmation receipts
- Complete Task Review - Display all tasks (completed and pending) in review mode
- Navigation Protection - Guard against leaving with uncompleted tasks
- Tab-Based Isolation - Process ID derived from tab context for multi-tab support
- Integration with Task List - Leverages shared task list component in review appearance
- Print Service Integration - Automatic batch printing of all return receipts for the process
- Clean UI Design - Minimal, focused interface emphasizing completion status
- E2E Testing Support - Comprehensive
data-whatanddata-whichattributes
Quick Start
1. Import Routes
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'return-review',
loadChildren: () =>
import('@isa/oms/feature/return-review').then((m) => m.routes),
},
];
2. Navigate to Review
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-return-process',
template: '...',
})
export class ReturnProcessComponent {
#router = inject(Router);
completeReturn(): void {
// After completing return process
this.#router.navigate(['/return-review']);
}
}
3. Basic Usage
The component is automatically loaded via routing and requires no configuration:
// Route automatically loads the component
// Component uses tab context for process ID
// Task list automatically fetches and displays all tasks
Core Concepts
Return Review Workflow
The return review represents the final step in the return process:
- Return Process Completion - User completes all required return process steps
- Navigation to Review - System navigates to return review route
- Success Confirmation - User sees "Die Rückgabe war erfolgreich!" message
- Task Review - All tasks (completed and pending) displayed in table layout
- Receipt Printing - Optional reprint of return confirmation receipts
- Task Completion - User completes any remaining pending tasks
- Navigation Away - Guard checks for uncompleted tasks before allowing exit
Process ID and Tab Context
The library uses injectTabId() from @isa/core/tabs to derive the process ID:
processId = injectTabId(); // Returns Signal<number | undefined>
Benefits:
- Multiple return processes can run simultaneously in different tabs
- Each tab maintains independent state
- No manual process ID management required
- Process ID automatically propagates to child components
Task List Integration
The review component delegates task display to ReturnTaskListComponent:
<oms-shared-return-task-list
[appearance]="'review'"
></oms-shared-return-task-list>
Key Behaviors:
- Appearance Mode:
'review'shows all tasks (completed and pending) - Layout: Table-based grid layout optimized for scanning
- Read-Only: Tasks can be marked as completed but not modified
- Automatic Fetching: Component fetches tasks on initialization
Receipt Printing
The component provides receipt reprinting functionality:
async printReceipt() {
const processId = this.processId();
if (processId) {
// 1. Fetch all return processes for this process ID
const receiptIds = this.#returnProcessStore
.entities()
.filter((p) => p.processId === processId && p.returnReceipt?.id)
.map((p) => p.returnReceipt!.id);
// 2. Print all receipts in batch
await this.#printReceiptsService.printReturnReceipts({
returnReceiptIds: receiptIds,
});
}
}
Features:
- Batch printing of all return receipts for the process
- Filters out processes without return receipt IDs
- Handles printing failures gracefully
- No user feedback required (print service handles UI)
Navigation Guard
The library includes UncompletedTasksGuard to prevent accidental navigation away from pending tasks:
canDeactivate(): boolean | Promise<boolean> {
const hasUncompletedTasks = this.uncompletedTaskListItems().length > 0;
if (!hasUncompletedTasks) {
return true; // Allow navigation
}
return this.openDialog(); // Confirm with user
}
Dialog Behavior:
- Title: "Aufgaben erledigen"
- Message: "Bitte schließen Sie die Aufgaben ab bevor Sie das die Rückgabe verlassen"
- Close Button: "Verlassen" (allows navigation)
- Confirm Button: "Zurück" (stays on page)
- Logic: Confirmation = stay, close/cancel = leave
Component API Reference
ReturnReviewComponent
Main component orchestrating the review interface.
Selector: oms-feature-return-review
Inputs: None (uses injected services and tab context)
Outputs: None (internal actions only)
Template Structure:
<oms-feature-return-review-header
(printReceipt)="printReceipt()"
></oms-feature-return-review-header>
<oms-shared-return-task-list
[appearance]="'review'"
></oms-shared-return-task-list>
Injected Services:
PrintReceiptsService- Receipt printing operationsReturnProcessStore- Access to return process entities
Computed Properties:
processId: Signal<number | undefined>- Derived from tab context viainjectTabId()
Methods:
printReceipt(): Promise<void>
Reprints all return confirmation receipts for the current process.
Behavior:
- Retrieves current process ID from tab context
- Filters return processes by process ID
- Extracts return receipt IDs from processes
- Calls print service with batch of receipt IDs
Error Handling:
- Print service handles errors internally
- No explicit error UI in component
- Failures logged by print service
Example:
// Triggered by header print button
await component.printReceipt();
// All return receipts for process printed via print service
Host Styling:
:host {
@apply flex flex-col w-full justify-start mt-6 p-6 bg-white rounded-2xl;
}
Change Detection: OnPush - Optimized change detection strategy
ReturnReviewHeaderComponent
Header component displaying success message and print button.
Selector: oms-feature-return-review-header
Inputs: None
Outputs:
printReceipt: EventEmitter<void>
Emitted when user clicks the print receipt button.
Payload: None (void event)
Template Structure:
<h2 class="isa-text-subtitle-1-regular">Die Rückgabe war erfolgreich!</h2>
<button
data-what="button"
data-which="print-receipt"
class="self-start"
(click)="printReceipt.emit()"
uiInfoButton
>
<span uiInfoButtonLabel>Rückgabe Bestätigung erneut drucken</span>
<ng-icon name="isaActionPrinter" uiInfoButtonIcon></ng-icon>
</button>
UI Components:
InfoButtonComponent- Styled button with icon and labelNgIconComponent- Icon rendering withisaActionPrintericon
Host Styling:
:host {
@apply w-full
flex
flex-col
gap-6
desktop:gap-0
desktop:flex-row
desktop:justify-between
desktop:items-center
border-b
border-solid
border-isa-neutral-300
pb-6;
}
Responsive Behavior:
- Mobile/Tablet: Vertical stack (flex-col) with 24px gap
- Desktop: Horizontal layout (flex-row) with space-between alignment
Change Detection: OnPush
TODO Note:
// TODO: Kann direkt in der Komponente gehandled werden
printReceipt = output<void>();
This comment suggests future refactoring to handle printing directly in the header component instead of emitting to parent. This would simplify the component hierarchy.
UncompletedTasksGuard
Navigation guard preventing users from leaving with uncompleted tasks.
Injection: { providedIn: 'root' } - Singleton guard
Implements: CanDeactivate<ReturnReviewComponent>
Injected Services:
ReturnTaskListStore- Task list state managementinjectConfirmationDialog()- Confirmation dialog serviceinjectTabId()- Tab-based process ID
Computed Properties:
uncompletedTaskListItems: Signal<ReceiptItemTaskListItem[]>
Computes list of uncompleted tasks for current process.
Logic:
- Retrieves process ID from tab context
- Fetches task list entity from store by process ID
- Filters tasks where
completed === false - Returns empty array if no process ID or no tasks
Example:
// Process ID = 123, tasks: [{ id: 1, completed: false }, { id: 2, completed: true }]
uncompletedTaskListItems() // Returns [{ id: 1, completed: false }]
Methods:
canDeactivate(): boolean | Promise<boolean>
Angular router guard method determining if navigation is allowed.
Return Values:
true- Allow navigation (no uncompleted tasks)Promise<boolean>- User confirmation required (has uncompleted tasks)
Logic:
const hasUncompletedTasks = this.uncompletedTaskListItems().length > 0;
if (!hasUncompletedTasks) {
return true; // No tasks pending - allow navigation
}
return this.openDialog(); // Confirm with user
openDialog(): Promise<boolean>
Opens confirmation dialog for user to decide on navigation.
Dialog Configuration:
{
title: 'Aufgaben erledigen',
data: {
message: 'Bitte schließen Sie die Aufgaben ab bevor Sie das die Rückgabe verlassen',
closeText: 'Verlassen',
confirmText: 'Zurück',
},
}
Return Logic:
const result = await firstValueFrom(confirmDialogRef.closed);
return !result?.confirmed; // Invert: confirmed = stay, close = leave
Dialog Result Mapping:
- User clicks "Zurück" (confirm):
confirmed === true→ returnsfalse(block navigation) - User clicks "Verlassen" (close):
confirmed === false→ returnstrue(allow navigation) - User dismisses dialog:
confirmed === undefined→ returnstrue(allow navigation)
Usage Examples
Basic Return Review Flow
import { Component } from '@angular/core';
import { ReturnReviewComponent } from '@isa/oms/feature/return-review';
@Component({
selector: 'app-returns-workflow',
standalone: true,
imports: [ReturnReviewComponent],
template: `
<oms-feature-return-review></oms-feature-return-review>
`
})
export class ReturnsWorkflowComponent {}
Routing Configuration
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'returns',
children: [
{
path: 'search',
loadChildren: () =>
import('@isa/oms/feature/return-search').then((m) => m.routes),
},
{
path: 'details/:receiptId',
loadChildren: () =>
import('@isa/oms/feature/return-details').then((m) => m.routes),
},
{
path: 'process',
loadChildren: () =>
import('@isa/oms/feature/return-process').then((m) => m.routes),
},
{
path: 'review',
loadChildren: () =>
import('@isa/oms/feature/return-review').then((m) => m.routes),
},
],
},
];
Programmatic Navigation to Review
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-return-process-complete',
template: '...',
})
export class ReturnProcessCompleteComponent {
#router = inject(Router);
finishReturn(): void {
// Navigate to review after completing return process
this.#router.navigate(['/returns/review']);
}
}
Custom Header with Print Button
import { Component } from '@angular/core';
import { ReturnReviewHeaderComponent } from '@isa/oms/feature/return-review';
@Component({
selector: 'app-custom-review-header',
standalone: true,
imports: [ReturnReviewHeaderComponent],
template: `
<div class="custom-header-container">
<div class="branding">
<img src="/assets/logo.svg" alt="Company Logo" />
</div>
<oms-feature-return-review-header
(printReceipt)="handlePrint()"
></oms-feature-return-review-header>
<div class="actions">
<button (click)="closeTab()">Fenster schließen</button>
</div>
</div>
`
})
export class CustomReviewHeaderComponent {
handlePrint(): void {
console.log('Printing receipts...');
}
closeTab(): void {
window.close();
}
}
Accessing Store for Custom Logic
import { Component, inject, computed } from '@angular/core';
import { ReturnProcessStore, ReturnTaskListStore } from '@isa/oms/data-access';
import { injectTabId } from '@isa/core/tabs';
import { ReturnReviewComponent } from '@isa/oms/feature/return-review';
@Component({
selector: 'app-advanced-review',
standalone: true,
imports: [ReturnReviewComponent],
template: `
<div class="review-header">
<h1>Rückgabe erfolgreich</h1>
@if (hasPendingTasks()) {
<p class="warning">
Sie haben noch {{ pendingTaskCount() }} ausstehende Aufgaben
</p>
}
</div>
<oms-feature-return-review></oms-feature-return-review>
`
})
export class AdvancedReviewComponent {
#taskStore = inject(ReturnTaskListStore);
processId = injectTabId();
pendingTaskCount = computed(() => {
const processId = this.processId();
if (!processId) return 0;
const entity = this.#taskStore.entityMap()[processId];
return entity?.data?.filter(task => !task.completed).length ?? 0;
});
hasPendingTasks = computed(() => this.pendingTaskCount() > 0);
}
Handling Navigation Guard Events
import { Component, inject } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';
@Component({
selector: 'app-review-with-navigation',
template: `
<div>
<button (click)="navigateBack()">Zurück zur Suche</button>
<oms-feature-return-review></oms-feature-return-review>
<button (click)="startNewReturn()">Neue Rückgabe</button>
</div>
`
})
export class ReviewWithNavigationComponent {
#router = inject(Router);
#location = inject(Location);
navigateBack(): void {
// Will trigger UncompletedTasksGuard if tasks are pending
this.#location.back();
}
startNewReturn(): void {
// Will trigger UncompletedTasksGuard if tasks are pending
this.#router.navigate(['/returns/search']);
}
}
Testing Guard Behavior
import { Component, inject, computed } from '@angular/core';
import { Router } from '@angular/router';
import { ReturnTaskListStore } from '@isa/oms/data-access';
import { injectTabId } from '@isa/core/tabs';
@Component({
selector: 'app-guard-test',
template: `
<div>
<p>Pending tasks: {{ pendingCount() }}</p>
<button (click)="tryNavigate()">Try Navigate</button>
<button (click)="completeTasks()">Complete All Tasks</button>
</div>
`
})
export class GuardTestComponent {
#router = inject(Router);
#store = inject(ReturnTaskListStore);
processId = injectTabId();
pendingCount = computed(() => {
const processId = this.processId();
if (!processId) return 0;
const entity = this.#store.entityMap()[processId];
return entity?.data?.filter(t => !t.completed).length ?? 0;
});
tryNavigate(): void {
// If pending tasks exist, guard will show confirmation dialog
this.#router.navigate(['/returns/search']);
}
completeTasks(): void {
// Complete all tasks to allow navigation without prompt
const processId = this.processId();
if (!processId) return;
const entity = this.#store.entityMap()[processId];
const tasks = entity?.data ?? [];
tasks.forEach(task => {
if (!task.completed) {
// Complete task via store update
this.#store.updateTaskListItem({
processId,
taskListItem: { ...task, completed: true }
});
}
});
}
}
Routing and Navigation
Route Configuration
The library exports routes configured with the navigation guard:
// libs/oms/feature/return-review/src/lib/routes.ts
import { Routes } from '@angular/router';
import { ReturnReviewComponent } from './return-review.component';
import { UncompletedTasksGuard } from './guards/uncompleted-tasks.guard';
export const routes: Routes = [
{
path: '',
component: ReturnReviewComponent,
canDeactivate: [UncompletedTasksGuard],
},
];
Key Features:
- Empty path: Component loads at parent route path
- Deactivation guard: Prevents navigation with uncompleted tasks
- Standalone component: No module wrapper required
Integration with OMS Workflow
The return review fits into the OMS workflow as follows:
Return Search → Return Details → Return Process → Return Review
(search) (select) (process) (confirm)
Navigation Flow:
- User searches for receipt (
/returns/search) - User selects items for return (
/returns/details/:receiptId) - User processes return items (
/returns/process) - System navigates to review (
/returns/review) - User reviews tasks and optionally reprints receipt
- User navigates away (with guard protection)
Programmatic Navigation
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-return-orchestrator',
template: '...'
})
export class ReturnOrchestratorComponent {
#router = inject(Router);
completeReturnProcess(): void {
// After completing return process steps
this.#router.navigate(['/returns/review']);
}
exitReview(): void {
// Navigate away from review
// Guard will check for uncompleted tasks
this.#router.navigate(['/returns/search']);
}
reopenProcess(): void {
// Navigate back to process from review
// Guard will check for uncompleted tasks
this.#router.navigate(['/returns/process']);
}
}
Guard Behavior in Routing
The UncompletedTasksGuard integrates with Angular router lifecycle:
Scenario 1: No Uncompleted Tasks
User clicks back button
→ Guard checks uncompleted tasks
→ None found
→ Navigation proceeds immediately
→ User sees previous route
Scenario 2: Uncompleted Tasks Exist
User clicks back button
→ Guard checks uncompleted tasks
→ Tasks found
→ Confirmation dialog opens
→ User clicks "Zurück" (confirm)
→ Dialog closes
→ Navigation blocked
→ User remains on review page
OR
→ User clicks "Verlassen" (close)
→ Dialog closes
→ Navigation proceeds
→ User sees previous route
Route Resolver Pattern (Future Enhancement)
Currently, the component relies on store state from previous routes. A future enhancement could use route resolvers:
// Potential future enhancement
export const routes: Routes = [
{
path: '',
component: ReturnReviewComponent,
canDeactivate: [UncompletedTasksGuard],
resolve: {
tasks: ReturnTasksResolver, // Ensures tasks are loaded
receipts: ReturnReceiptsResolver // Ensures receipts are available
}
},
];
This would guarantee data availability even if users navigate directly to the route.
Architecture Notes
Current Architecture
The library follows a component-based architecture with guard protection:
ReturnReviewComponent (orchestrator)
↓
├─→ ReturnReviewHeaderComponent (presentation)
│ └─→ InfoButtonComponent (UI)
│ └─→ NgIconComponent (UI)
├─→ ReturnTaskListComponent (shared feature)
│ └─→ [See @isa/oms/shared/task-list README]
├─→ PrintReceiptsService (printing)
├─→ ReturnProcessStore (state)
└─→ Tab Context (process isolation)
UncompletedTasksGuard (navigation protection)
↓
├─→ ReturnTaskListStore (task state)
├─→ ConfirmationDialog (UI)
└─→ Tab Context (process isolation)
Design Patterns
1. Container/Presenter Pattern
Container: ReturnReviewComponent
- Manages process ID via tab context
- Handles receipt printing logic
- Orchestrates child components
- Interacts with stores and services
Presenter: ReturnReviewHeaderComponent
- Pure presentation logic
- Emits events to parent
- No direct service dependencies (except icon provider)
- Receives all data via inputs (currently none)
Benefits:
- Clear separation of concerns
- Header component is highly reusable
- Easy to test in isolation
- Simple data flow
2. Tab-Based Process Isolation
// Each browser tab gets unique process context
processId = injectTabId(); // Signal<number | undefined>
// Guard also uses tab context
processId = injectTabId(); // Same signal, same value
// Store lookups use process ID
const entity = store.entityMap()[processId()];
Benefits:
- Multiple simultaneous return reviews in different tabs
- No cross-tab state interference
- Automatic cleanup when tab closes
- No manual process ID management
3. Declarative Routing with Guards
export const routes: Routes = [
{
path: '',
component: ReturnReviewComponent,
canDeactivate: [UncompletedTasksGuard], // Declarative protection
},
];
Benefits:
- Guard logic separate from component
- Reusable across routes
- Clear intent in route configuration
- Angular handles lifecycle automatically
4. Delegation to Shared Components
<oms-shared-return-task-list
[appearance]="'review'"
></oms-shared-return-task-list>
Benefits:
- No duplication of task list logic
- Consistent task display across features
- Shared component handles complexity
- Review component stays focused
5. Store-Based State Management
// Component reads from store
const receiptIds = this.#returnProcessStore
.entities()
.filter((p) => p.processId === processId && p.returnReceipt?.id)
.map((p) => p.returnReceipt!.id);
Benefits:
- Single source of truth
- Reactive updates
- Store managed elsewhere (return process feature)
- Component consumes, doesn't manage
Known Architectural Considerations
1. Header Component Output Pattern (Low Priority)
Current State:
// ReturnReviewHeaderComponent
printReceipt = output<void>();
// ReturnReviewComponent
<oms-feature-return-review-header
(printReceipt)="printReceipt()"
></oms-feature-return-review-header>
TODO Comment:
// TODO: Kann direkt in der Komponente gehandled werden
Issue:
- Header emits event to parent
- Parent handles printing logic
- Unnecessary indirection for single-purpose button
Proposed Refactoring:
// ReturnReviewHeaderComponent (future)
export class ReturnReviewHeaderComponent {
#printReceiptsService = inject(PrintReceiptsService);
#returnProcessStore = inject(ReturnProcessStore);
processId = injectTabId();
async printReceipt(): void {
// Handle printing directly in header
const processId = this.processId();
if (processId) {
const receiptIds = this.#returnProcessStore
.entities()
.filter((p) => p.processId === processId && p.returnReceipt?.id)
.map((p) => p.returnReceipt!.id);
await this.#printReceiptsService.printReturnReceipts({
returnReceiptIds: receiptIds,
});
}
}
}
// ReturnReviewComponent (future)
<oms-feature-return-review-header></oms-feature-return-review-header>
// No output binding needed
Benefits:
- Simpler component tree
- No unnecessary event propagation
- Header is self-contained
- Parent component simplified
Impact: Low - current pattern works, refactoring is optimization
2. Guard State Duplication (Medium Priority)
Current State:
// UncompletedTasksGuard
#returnTaskListStore = inject(ReturnTaskListStore);
processId = injectTabId();
uncompletedTaskListItems = computed(() => {
const processId = this.processId();
// ... filter logic
});
// Similar logic could exist in components
Issue:
- Guard recomputes uncompleted tasks
- Component might have similar logic
- Potential duplication of filtering logic
- Guard is stateful (uses computed signals)
Proposed Enhancement:
// Create shared service
export class ReturnTaskValidator {
#store = inject(ReturnTaskListStore);
hasUncompletedTasks(processId: number): boolean {
const entity = this.#store.entityMap()[processId];
return entity?.data?.some(task => !task.completed) ?? false;
}
getUncompletedTasks(processId: number): ReceiptItemTaskListItem[] {
const entity = this.#store.entityMap()[processId];
return entity?.data?.filter(task => !task.completed) ?? [];
}
}
// Guard uses service
export class UncompletedTasksGuard implements CanDeactivate<ReturnReviewComponent> {
#validator = inject(ReturnTaskValidator);
processId = injectTabId();
canDeactivate(): boolean | Promise<boolean> {
const processId = this.processId();
if (!processId || !this.#validator.hasUncompletedTasks(processId)) {
return true;
}
return this.openDialog();
}
}
Benefits:
- Centralized task validation logic
- Guard becomes stateless function
- Reusable across components and guards
- Easier to test
Impact: Medium - improves maintainability and testability
3. Print Failure User Feedback (Medium Priority)
Current State:
async printReceipt() {
// ...
await this.#printReceiptsService.printReturnReceipts({
returnReceiptIds: receiptIds,
});
// No error handling, no user feedback
}
Issue:
- Print service may fail silently
- User doesn't know if printing succeeded
- No retry mechanism
- No loading indicator
Proposed Enhancement:
async printReceipt() {
const processId = this.processId();
if (!processId) return;
const receiptIds = this.#returnProcessStore
.entities()
.filter((p) => p.processId === processId && p.returnReceipt?.id)
.map((p) => p.returnReceipt!.id);
try {
// Show loading state
this.isPrinting.set(true);
await this.#printReceiptsService.printReturnReceipts({
returnReceiptIds: receiptIds,
});
// Show success toast
this.#toastService.success('Belege erfolgreich gedruckt');
} catch (error) {
// Show error toast with retry option
this.#toastService.error('Drucken fehlgeschlagen', {
action: {
label: 'Erneut versuchen',
handler: () => this.printReceipt()
}
});
} finally {
this.isPrinting.set(false);
}
}
Benefits:
- Clear user feedback
- Error recovery mechanism
- Loading state prevents double-clicks
- Better UX
Impact: Medium - improves user experience and error handling
4. Direct Store Entity Access (Low Priority)
Current State:
const receiptIds = this.#returnProcessStore
.entities()
.filter((p) => p.processId === processId && p.returnReceipt?.id)
.map((p) => p.returnReceipt!.id);
Observation:
- Component directly accesses store entities
- Filter logic embedded in component
- Non-null assertion (
!) indicates assumed structure - No type guard for returnReceipt existence
Proposed Enhancement:
// Add selector to store or create service
export class ReturnProcessStore extends SignalStore {
// ... existing store code
getReceiptIdsForProcess(processId: number): number[] {
return this.entities()
.filter((p) =>
p.processId === processId &&
p.returnReceipt?.id !== undefined
)
.map((p) => p.returnReceipt.id);
}
}
// Component uses selector
const receiptIds = this.#returnProcessStore.getReceiptIdsForProcess(processId);
Benefits:
- Encapsulates query logic in store
- Type-safe, no non-null assertions
- Reusable across components
- Easier to test and maintain
Impact: Low - current pattern works, enhancement improves encapsulation
Performance Considerations
1. Tab-Based Process Isolation
- O(1) store lookup via process ID
- No iteration over all processes
- Isolated state prevents memory leaks
- Efficient cleanup when tab closes
2. Delegated Task Rendering
- Task list component handles virtualization
- Review component has minimal rendering logic
- Deferred rendering in task list reduces initial load
- OnPush change detection minimizes checks
3. Guard Computation
uncompletedTaskListItems = computed(() => {
// Only recomputes when dependencies change
// processId signal or store entity signal
});
- Computed signals cache results
- Only recalculates when process ID or tasks change
- Efficient for navigation checks
4. Store Entity Access
this.#returnProcessStore.entities()
- Direct array access (no observable subscription)
- Filters executed on-demand
- No memory held by subscriptions
- Component cleanup is simple
Future Enhancements
Potential improvements identified:
- Loading States - Add loading indicator during receipt printing
- Success Feedback - Toast notification on successful print
- Error Handling - User-visible error messages with retry option
- Print Preview - Show preview before printing all receipts
- Selective Printing - Allow printing individual receipts
- Task Summary - Show completion statistics (e.g., "3 of 5 tasks completed")
- Export Functionality - Export task list to PDF or CSV
- Time Tracking - Display time taken for return process
- Header Refactoring - Move print logic into header component
- Shared Validator - Extract task validation to shared service
- Route Resolver - Add resolver to ensure data availability
- Accessibility - ARIA labels and keyboard navigation for guard dialog
Dependencies
Required Libraries
Angular Core
@angular/core- Angular framework, signals, dependency injection@angular/common- Common Angular utilities (Location service)
OMS Domain
Feature Libraries:
@isa/oms/shared/task-list- Task list component in review modeReturnTaskListComponent- Main task display component
Data Access:
@isa/oms/data-access- Data services and storesPrintReceiptsService- Receipt printing operationsReturnProcessStore- Return process state managementReturnTaskListStore- Task list state managementReceiptItemTaskListItem- Task data model
UI Components
@isa/ui/buttons- Button componentsInfoButtonComponent- Info button with icon and label
@isa/ui/dialog- Dialog componentsinjectConfirmationDialog()- Confirmation dialog service
Core Services
@isa/core/tabs- Tab context managementinjectTabId()- Process ID from tab context
Icons
@isa/icons- ISA icon setisaActionPrinter- Printer icon
@ng-icons/core- Icon component infrastructureNgIconComponent- Icon rendering componentprovideIcons()- Icon provider function
State Management
@ngrx/signals- Signal-based state management (indirect via stores)
Utilities
rxjs- Reactive programmingfirstValueFrom- Promise conversion for observables
Development Dependencies
jest- Testing framework@nx/jest- Nx Jest integrationjest-preset-angular- Angular-specific Jest configuration@angular/compiler-cli- Angular compilertypescript- TypeScript compiler
Path Alias
Import from: @isa/oms/feature/return-review
Example:
import { routes } from '@isa/oms/feature/return-review';
Testing
The library uses Jest with Angular Testing Utilities for testing.
Running Tests
# Run tests for this library
npx nx test return-review --skip-nx-cache
# Run tests with coverage
npx nx test return-review --code-coverage --skip-nx-cache
# Run tests in watch mode
npx nx test return-review --watch
# Run specific test file
npx nx test return-review --testFile=src/lib/return-review.component.spec.ts --skip-nx-cache
Test Structure (Recommended)
Testing ReturnReviewComponent
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { ReturnReviewComponent } from './return-review.component';
import { PrintReceiptsService, ReturnProcessStore } from '@isa/oms/data-access';
import { provideLocationMocks } from '@angular/common/testing';
import { signal } from '@angular/core';
describe('ReturnReviewComponent', () => {
let component: ReturnReviewComponent;
let fixture: ComponentFixture<ReturnReviewComponent>;
let mockPrintService: jest.Mocked<PrintReceiptsService>;
let mockStore: jest.Mocked<ReturnProcessStore>;
beforeEach(async () => {
mockPrintService = {
printReturnReceipts: jest.fn(() => Promise.resolve())
} as any;
mockStore = {
entities: jest.fn(() => [])
} as any;
await TestBed.configureTestingModule({
imports: [ReturnReviewComponent],
providers: [
{ provide: PrintReceiptsService, useValue: mockPrintService },
{ provide: ReturnProcessStore, useValue: mockStore },
provideLocationMocks(),
]
}).compileComponents();
fixture = TestBed.createComponent(ReturnReviewComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should print receipts when printReceipt is called', async () => {
// Arrange
const mockProcessId = 123;
component['processId'] = signal(mockProcessId);
const mockProcesses = [
{ processId: 123, returnReceipt: { id: 1 } },
{ processId: 123, returnReceipt: { id: 2 } },
{ processId: 456, returnReceipt: { id: 3 } }, // Different process
];
mockStore.entities.mockReturnValue(mockProcesses as any);
// Act
await component.printReceipt();
// Assert
expect(mockPrintService.printReturnReceipts).toHaveBeenCalledWith({
returnReceiptIds: [1, 2]
});
});
it('should filter out processes without return receipt ID', async () => {
// Arrange
const mockProcessId = 123;
component['processId'] = signal(mockProcessId);
const mockProcesses = [
{ processId: 123, returnReceipt: { id: 1 } },
{ processId: 123, returnReceipt: null }, // No receipt
{ processId: 123 }, // No returnReceipt property
];
mockStore.entities.mockReturnValue(mockProcesses as any);
// Act
await component.printReceipt();
// Assert
expect(mockPrintService.printReturnReceipts).toHaveBeenCalledWith({
returnReceiptIds: [1]
});
});
it('should not print if process ID is undefined', async () => {
// Arrange
component['processId'] = signal(undefined);
// Act
await component.printReceipt();
// Assert
expect(mockPrintService.printReturnReceipts).not.toHaveBeenCalled();
});
});
Testing ReturnReviewHeaderComponent
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from '@jest/globals';
import { ReturnReviewHeaderComponent } from './return-review-header.component';
describe('ReturnReviewHeaderComponent', () => {
let component: ReturnReviewHeaderComponent;
let fixture: ComponentFixture<ReturnReviewHeaderComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReturnReviewHeaderComponent]
}).compileComponents();
fixture = TestBed.createComponent(ReturnReviewHeaderComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display success message', () => {
// Arrange & Act
fixture.detectChanges();
// Assert
const heading = fixture.nativeElement.querySelector('h2');
expect(heading?.textContent).toContain('Die Rückgabe war erfolgreich!');
});
it('should emit printReceipt event when button clicked', () => {
// Arrange
let emitted = false;
component.printReceipt.subscribe(() => {
emitted = true;
});
fixture.detectChanges();
// Act
const button = fixture.nativeElement.querySelector(
'[data-what="button"][data-which="print-receipt"]'
);
button?.click();
// Assert
expect(emitted).toBe(true);
});
it('should have correct E2E testing attributes', () => {
// Arrange & Act
fixture.detectChanges();
// Assert
const button = fixture.nativeElement.querySelector(
'[data-what="button"][data-which="print-receipt"]'
);
expect(button).toBeTruthy();
expect(button.getAttribute('data-what')).toBe('button');
expect(button.getAttribute('data-which')).toBe('print-receipt');
});
it('should display printer icon', () => {
// Arrange & Act
fixture.detectChanges();
// Assert
const icon = fixture.nativeElement.querySelector('ng-icon');
expect(icon).toBeTruthy();
expect(icon.getAttribute('name')).toBe('isaActionPrinter');
});
});
Testing UncompletedTasksGuard
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { UncompletedTasksGuard } from './uncompleted-tasks.guard';
import { ReturnTaskListStore } from '@isa/oms/data-access';
import { signal } from '@angular/core';
import { of } from 'rxjs';
describe('UncompletedTasksGuard', () => {
let guard: UncompletedTasksGuard;
let mockStore: jest.Mocked<ReturnTaskListStore>;
let mockDialog: jest.Mock;
beforeEach(() => {
mockStore = {
entityMap: jest.fn(() => ({}))
} as any;
mockDialog = jest.fn();
TestBed.configureTestingModule({
providers: [
UncompletedTasksGuard,
{ provide: ReturnTaskListStore, useValue: mockStore },
]
});
guard = TestBed.inject(UncompletedTasksGuard);
guard['processId'] = signal(123);
guard['#confirmationDialog'] = mockDialog as any;
});
it('should create', () => {
expect(guard).toBeTruthy();
});
it('should allow navigation when no uncompleted tasks', () => {
// Arrange
const mockTasks = [
{ id: 1, completed: true },
{ id: 2, completed: true }
];
mockStore.entityMap.mockReturnValue({
123: { id: 123, data: mockTasks, status: 'Success' }
} as any);
// Act
const result = guard.canDeactivate();
// Assert
expect(result).toBe(true);
expect(mockDialog).not.toHaveBeenCalled();
});
it('should block navigation and show dialog when uncompleted tasks exist', async () => {
// Arrange
const mockTasks = [
{ id: 1, completed: true },
{ id: 2, completed: false } // Uncompleted task
];
mockStore.entityMap.mockReturnValue({
123: { id: 123, data: mockTasks, status: 'Success' }
} as any);
mockDialog.mockReturnValue({
closed: of({ confirmed: true }) // User clicks "Zurück"
});
// Act
const result = await guard.canDeactivate();
// Assert
expect(mockDialog).toHaveBeenCalled();
expect(result).toBe(false); // Block navigation
});
it('should allow navigation if user confirms leaving', async () => {
// Arrange
const mockTasks = [
{ id: 1, completed: false }
];
mockStore.entityMap.mockReturnValue({
123: { id: 123, data: mockTasks, status: 'Success' }
} as any);
mockDialog.mockReturnValue({
closed: of({ confirmed: false }) // User clicks "Verlassen"
});
// Act
const result = await guard.canDeactivate();
// Assert
expect(result).toBe(true); // Allow navigation
});
it('should handle missing process ID', () => {
// Arrange
guard['processId'] = signal(undefined);
// Act
const result = guard.canDeactivate();
// Assert
expect(result).toBe(true);
});
it('should handle missing task data', () => {
// Arrange
mockStore.entityMap.mockReturnValue({
123: { id: 123, data: undefined, status: 'Pending' }
} as any);
// Act
const result = guard.canDeactivate();
// Assert
expect(result).toBe(true);
});
});
Test Coverage Goals
- Component Logic: 80%+ coverage
- Guard Logic: 90%+ coverage (critical for data integrity)
- Template Rendering: Test all conditional paths
- User Interactions: Test all button clicks and events
- Store Integration: Mock store methods and verify calls
- E2E Attributes: Verify all data-what/data-which attributes exist
E2E Testing
The component includes E2E testing attributes for automated testing:
// Example Playwright test
import { test, expect } from '@playwright/test';
test.describe('Return Review', () => {
test('should display success message', async ({ page }) => {
// Navigate to review page
await page.goto('/returns/review');
// Verify success message
await expect(page.locator('h2')).toContainText('Die Rückgabe war erfolgreich!');
});
test('should print receipt on button click', async ({ page }) => {
await page.goto('/returns/review');
// Monitor for print dialog or download
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('[data-what="button"][data-which="print-receipt"]')
]);
expect(download).toBeTruthy();
});
test('should show task list in review mode', async ({ page }) => {
await page.goto('/returns/review');
// Verify task list component is present
await expect(
page.locator('oms-shared-return-task-list')
).toBeVisible();
});
test('should prompt when navigating with uncompleted tasks', async ({ page }) => {
await page.goto('/returns/review');
// Assume tasks exist and are not completed
page.on('dialog', async (dialog) => {
expect(dialog.message()).toContain('Aufgaben erledigen');
await dialog.dismiss(); // Click "Verlassen"
});
// Try to navigate away
await page.click('[data-which="back-button"]');
// Should show dialog (handled by event listener)
});
});
Best Practices
1. Rely on Tab Context for Process ID
// ✅ GOOD: Component automatically uses tab-based process ID
// No manual process ID management needed
<oms-feature-return-review></oms-feature-return-review>
// ❌ BAD: Trying to manually pass process ID
// Component handles this internally via injectTabId()
<oms-feature-return-review [processId]="123"></oms-feature-return-review>
2. Trust the Navigation Guard
// ✅ GOOD: Guard automatically checks for uncompleted tasks
// No additional confirmation logic needed in components
// ❌ BAD: Duplicate confirmation logic
async navigateAway() {
const hasTasks = this.checkUncompletedTasks(); // Guard already does this
if (hasTasks) {
const confirm = await this.showDialog();
if (!confirm) return;
}
this.router.navigate(['/search']);
}
3. Let Task List Handle Display
// ✅ GOOD: Use shared task list component
<oms-shared-return-task-list
[appearance]="'review'"
></oms-shared-return-task-list>
// ❌ BAD: Duplicate task list implementation
<div *ngFor="let task of tasks()">
<!-- Custom task rendering duplicates shared component -->
</div>
4. Use E2E Attributes Consistently
// ✅ GOOD: Include data attributes for testing
<button
(click)="handleAction()"
data-what="button"
data-which="custom-action"
>
Custom Action
</button>
// ❌ BAD: No testing attributes
<button (click)="handleAction()">
Custom Action
</button>
5. Handle Print Failures Gracefully
// ✅ GOOD: Wrap print calls in try-catch
try {
await this.printReceipt();
// Consider adding success feedback
} catch (error) {
console.error('Print failed', error);
// Consider showing error message to user
}
// ❌ BAD: Assume printing always succeeds
await this.printReceipt(); // No error handling
6. Respect Component Hierarchy
// ✅ GOOD: Use component as designed
<oms-feature-return-review-header
(printReceipt)="handlePrint()"
></oms-feature-return-review-header>
// ❌ BAD: Bypass component and access services directly
// Breaks encapsulation
const header = this.headerComponent;
header.#printReceiptsService.printReturnReceipts(...);
7. Navigate After Process Completion
// ✅ GOOD: Navigate to review as final step
async completeReturn() {
await this.returnService.complete();
this.router.navigate(['/returns/review']);
}
// ❌ BAD: Navigate before process completes
this.router.navigate(['/returns/review']); // May show incomplete state
await this.returnService.complete();
8. Style with CSS Classes, Not Inline Styles
// ✅ GOOD: Override via CSS classes
oms-feature-return-review-header {
padding: 2rem;
background-color: var(--custom-bg);
}
// ❌ BAD: Inline styles in template
<oms-feature-return-review-header
style="padding: 2rem;"
></oms-feature-return-review-header>
9. Handle Missing Process ID
// ✅ GOOD: Check for process ID before operations
async printReceipt() {
const processId = this.processId();
if (!processId) {
console.warn('No process ID available');
return;
}
// Continue with printing
}
// ❌ BAD: Assume process ID always exists
async printReceipt() {
const processId = this.processId()!; // Non-null assertion
// Will fail if processId is undefined
}
10. Test Guard Behavior
// ✅ GOOD: Test all guard scenarios
it('should allow navigation without tasks', () => {
expect(guard.canDeactivate()).toBe(true);
});
it('should block navigation with tasks', async () => {
// Setup uncompleted tasks
const result = await guard.canDeactivate();
expect(result).toBe(false);
});
// ❌ BAD: Only test happy path
it('should work', () => {
expect(guard.canDeactivate()).toBe(true);
});
11. Consider Accessibility
// ✅ GOOD: Add ARIA labels and roles
<div role="status" aria-live="polite">
<h2>Die Rückgabe war erfolgreich!</h2>
</div>
<button
aria-label="Rückgabe Bestätigung erneut drucken"
(click)="printReceipt()"
>
<ng-icon name="isaActionPrinter"></ng-icon>
</button>
// ⚠️ CURRENT STATE: Limited accessibility
// Consider enhancement for screen reader support
12. Monitor Store State for Advanced Features
// ✅ GOOD: Use computed signals for derived data
import { computed } from '@angular/core';
receiptCount = computed(() => {
const processId = this.processId();
if (!processId) return 0;
return this.#returnProcessStore
.entities()
.filter(p => p.processId === processId && p.returnReceipt?.id)
.length;
});
// ❌ BAD: Direct store manipulation
this.#returnProcessStore.entities()[0] = newData; // Breaks store contract
License
Internal ISA Frontend library - not for external distribution.