✨ feat(navigation): implement title management and enhance tab system This commit introduces a comprehensive title management system and extends the tab functionality with subtitle support, improving navigation clarity and user experience across the application. Key changes: Title Management System: - Add @isa/common/title-management library with dual approach: - IsaTitleStrategy for route-based static titles - usePageTitle() for component-based dynamic titles - Implement TitleRegistryService for nested component hierarchies - Automatic ISA prefix addition and TabService integration - Comprehensive test coverage (1,158 lines of tests) Tab System Enhancement: - Add subtitle field to tab schema for additional context - Update TabService API (addTab, patchTab) to support subtitles - Extend Zod schemas with subtitle validation - Update documentation with usage examples Routing Modernization: - Consolidate route guards using ActivateProcessIdWithConfigKeyGuard - Replace 4+ specific guards with generic config-key-based approach - Add title attributes to 100+ routes across all modules - Remove deprecated ProcessIdGuard in favor of ActivateProcessIdGuard Code Cleanup: - Remove deprecated preview component and related routes - Clean up unused imports and exports - Update TypeScript path aliases Dependencies: - Update package.json and package-lock.json - Add @isa/common/title-management to tsconfig path mappings Refs: #5351, #5418, #5419, #5420
@isa/oms/feature/return-search
A comprehensive return search feature library for Angular applications, providing intelligent receipt search, filtering, and navigation capabilities for the Order Management System (OMS).
Overview
The Return Search Feature library provides a complete user interface for searching and browsing receipt records in the return process workflow. It offers advanced filtering, sorting, and pagination capabilities with intelligent navigation patterns, automatic single-result handling, and responsive design. The library integrates with the OMS data access layer and shared filter system to deliver a seamless search experience.
Table of Contents
- Features
- Quick Start
- Core Concepts
- Component Reference
- Routing Configuration
- Usage Examples
- State Management
- Filter Integration
- Responsive Design
- Testing
- Architecture Notes
- Dependencies
Features
- Multi-criteria search - Search by receipt number, email, customer name, or QR code
- Advanced filtering - Dynamic filter menu with configurable filter inputs
- Flexible sorting - Order-by toolbar with customizable sort options
- Infinite scrolling - Automatic pagination with viewport-based loading
- Intelligent navigation - Auto-redirect to receipt details when only one result found
- Responsive design - Adaptive layouts for mobile, tablet, and desktop screens
- State persistence - Filter state synchronized with query parameters
- Scroll restoration - Automatic scroll position restoration when navigating back
- Task list integration - Display ongoing return tasks alongside search interface
- Empty state handling - User-friendly messages for no results scenarios
- Loading indicators - Progressive loading states for search, pagination, and items
- QR code scanning - Support for scanning receipt QR codes from emails
Quick Start
1. Import Routes
import { Routes } from '@angular/router';
const routes: Routes = [
{
path: 'returns',
loadChildren: () =>
import('@isa/oms/feature/return-search').then((m) => m.routes),
},
];
2. Navigate to Search
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-returns-dashboard',
template: `
<button (click)="startReturnSearch()">Start Return Search</button>
`
})
export class ReturnsDashboardComponent {
#router = inject(Router);
startReturnSearch() {
this.#router.navigate(['/returns']);
}
}
3. Basic Search Flow
The search flow is automatically handled by the library:
- User enters search term (receipt number, email, or customer name)
- User clicks search or presses Enter
- If exactly 1 result: Navigate directly to receipt details
- If multiple results: Display results list with filters and sorting
- User clicks result: Navigate to receipt details
Core Concepts
Search Process
The return search follows a multi-step process managed by the ReturnSearchStore:
- Query Building - Filter inputs are collected and combined into a search query
- Query Execution - The query is sent to the backend via
ReturnSearchService - Result Handling - Results are stored in the NgRx Signals store per process ID
- Navigation Decision - Single results trigger automatic navigation
- Pagination - Additional results loaded via infinite scroll
Process ID and Tabs
The library uses a tab-based architecture where each search session is identified by a unique processId:
// Each browser tab gets a unique process ID
private _processId = injectTabId();
// Search results are stored per process ID
this._returnSearchStore.search({
processId,
query: this._filterService.query(),
clear: true
});
// Entity retrieval uses the process ID
const entity = this._returnSearchStore.getEntity(processId);
This allows multiple concurrent search sessions across different browser tabs without data conflicts.
Filter System Integration
The library integrates with @isa/shared/filter for advanced filtering:
- Query Settings Resolver - Loads filter configuration from backend
- Query Params Sync - Synchronizes filter state with URL query parameters
- Filter Groups - Separates search input from filter inputs
- Commit Pattern - Changes are committed before search execution
Search Status States
Search operations progress through defined states:
enum ReturnSearchStatus {
Idle = 'idle', // No search performed yet
Pending = 'pending', // Search in progress
Success = 'success', // Search completed successfully
Error = 'error' // Search failed
}
Component Reference
ReturnSearchComponent
Root component providing filter context and routing outlet.
Selector: oms-feature-return-search
Template: Simple router outlet for child routes
Providers:
provideFilter()- Filter service with query settings factory and params sync
Host Classes:
flex flex-col gap-5 isa-desktop:gap-6 items-center overflow-x-hidden
Usage:
// Automatically used when routes are loaded
// No direct usage required
ReturnSearchMainComponent
Main search interface with search bar, filters, and task list.
Selector: oms-feature-return-search-main
Inputs: None (uses injected services)
Outputs: None (navigation-based)
Key Features:
- Search bar with QR code scanning support
- Dynamic filter input buttons
- Pending state indicator
- Task list integration
- Automatic navigation based on result count
Template Structure:
<div class="search-header">
<h1>Rückgabe starten</h1>
<filter-search-bar-input (triggerSearch)="onSearch()">
<filter-input-menu-button *ngFor="let filter of filterInputs()">
</div>
<oms-shared-return-task-list>
Computed Signals:
| Signal | Type | Description |
|---|---|---|
entityPending |
boolean |
True when search is in pending state |
filterInputs |
FilterInput[] |
Filtered list of filter inputs (group='filter') |
Methods:
onSearch(): void
Initiates a new search with current filter values.
Behavior:
- Commits current filter state
- Clears previous results
- Executes search with callback
- Automatically navigates based on result count
Example:
// Triggered by search bar or filter changes
onSearch() {
const processId = this._processId();
if (processId) {
this._filterService.commit();
this._returnSearchStore.search({
processId,
query: this._filterService.query(),
clear: true,
cb: this.onSearchCb
});
}
}
onSearchCb(result: CallbackResult<ListResponseArgs<ReceiptListItem>>): void
Callback for search results, handles automatic navigation.
Navigation Rules:
- 1 result: Navigate to
receipt/:id - Multiple results: Navigate to
receiptslist - 0 results: Stay on search page (handled by result component)
ReturnSearchResultComponent
Results list with filtering, sorting, and infinite scroll pagination.
Selector: oms-feature-return-search-result
Inputs: None (uses route data and injected services)
Outputs: None (navigation-based)
Key Features:
- Responsive filter and sort controls
- Infinite scroll pagination with viewport detection
- Scroll position restoration
- Empty state handling
- Progressive loading indicators
- Mobile-optimized layout
Template Structure:
<filter-search-bar-input (triggerSearch)="search()">
<filter-filter-menu-button>
<filter-order-by-toolbar>
<span>{{ entityHits() }} Einträge</span>
@for (item of entityItems(); track item.id) {
<a [routerLink]="['../', 'receipt', item.id]">
<oms-feature-return-search-result-item [item]="item">
</a>
}
@if (renderPageTrigger()) {
<div (uiInViewport)="paging($event)"></div>
}
Computed Signals:
| Signal | Type | Description |
|---|---|---|
entity |
ReturnSearchEntity | undefined |
Current search entity for this process |
entityItems |
ReceiptListItem[] |
List of receipt items in current search |
entityHits |
number |
Total number of search results |
entityStatus |
ReturnSearchStatus |
Current status of search operation |
renderItemList |
boolean |
Whether to render the item list |
renderPagingLoader |
boolean |
Whether to show pagination loading indicator |
renderSearchLoader |
boolean |
Whether to show initial search loader |
renderPageTrigger |
boolean |
Whether to render pagination trigger element |
mobileBreakpoint |
boolean |
True when viewport is tablet size or below |
showOrderByToolbarMobile |
boolean |
Controls order-by toolbar visibility on mobile |
Methods:
search(): void
Initiates a new search, clearing previous results.
Behavior:
- Commits filter changes
- Clears existing results
- Navigates to receipt if only 1 result
paging(inViewport: boolean): void
Handles infinite scroll pagination when trigger enters viewport.
Parameters:
inViewport: boolean- Whether pagination trigger is visible
Behavior:
- Only triggers when element is in viewport
- Appends results to existing list (clear: false)
- Uses same filter query as original search
Example:
<!-- Pagination trigger -->
@if (renderPageTrigger()) {
<div (uiInViewport)="paging($event)"></div>
}
navigate(path: (string | number)[]): void
Navigates to a route while preserving query parameters.
Parameters:
path: (string | number)[]- Route segments
Behavior:
- Navigates relative to current route
- Preserves all filter query parameters
ngAfterViewInit(): void
Lifecycle hook that restores scroll position after view initialization.
ReturnSearchResultItemComponent
Individual result item displaying receipt information.
Selector: oms-feature-return-search-result-item
Inputs:
| Input | Type | Required | Description |
|---|---|---|---|
item |
ReceiptListItem |
Yes | Receipt data to display |
Outputs: None (clickable container handled by parent)
Template Structure:
<ui-client-row>
<ui-client-row-content>
<h3>{{ name() }}</h3>
</ui-client-row-content>
<ui-item-row-data>
<ui-item-row-data-row>
<ui-item-row-data-label>Belegdatum</ui-item-row-data-label>
<ui-item-row-data-value>{{ receiptDate() | date }}</ui-item-row-data-value>
</ui-item-row-data-row>
<!-- Receipt number, order number -->
</ui-item-row-data>
<ui-item-row-data>
<!-- Email, address -->
</ui-item-row-data>
</ui-client-row>
Computed Signals:
| Signal | Type | Description |
|---|---|---|
name |
string |
Formatted customer name (person or organization) |
receiptDate |
string | undefined |
Date when receipt was printed |
receiptNumber |
string | undefined |
Receipt number identifier |
orderNumber |
string | undefined |
Order number identifier |
email |
string | undefined |
Customer email from billing info |
address |
string |
Formatted address (ZIP + City) from billing or shipping |
Address Resolution Logic:
// Prefers billing address, falls back to shipping address
address = computed(() => {
const address = this.item()?.billing?.address ?? this.item()?.shipping?.address;
return address ? [address.zipCode, address.city].join(' ') : '';
});
E2E Attributes:
<ui-client-row
data-what="search-result-item"
[attr.data-which]="receiptNumber()"
>
Usage:
@for (item of items; track item.id) {
<a [routerLink]="['receipt', item.id]">
<oms-feature-return-search-result-item [item]="item" />
</a>
}
querySettingsResolverFn
Route resolver that loads filter configuration from backend.
Type: ResolveFn<QuerySettingsDTO>
Purpose: Fetches query settings before route activation to configure filter system
Returns: QuerySettingsDTO with filter field definitions
Usage:
{
path: '',
component: ReturnSearchComponent,
resolve: { querySettings: querySettingsResolverFn }
}
Data Flow:
- Router activates route
- Resolver fetches query settings via
ReturnSearchService.querySettings() - Query settings passed to component via
ActivatedRoute.snapshot.data - Filter system configured with settings
Routing Configuration
Route Structure
export const routes: Routes = [
{
path: '',
component: ReturnSearchComponent,
resolve: { querySettings: querySettingsResolverFn },
children: [
{
path: '',
component: ReturnSearchMainComponent
},
{
path: 'receipts',
component: ReturnSearchResultComponent,
data: { scrollPositionRestoration: true }
}
]
},
{
path: 'receipt',
loadChildren: () =>
import('@isa/oms/feature/return-details').then((feat) => feat.routes)
},
{
path: 'process',
loadChildren: () =>
import('@isa/oms/feature/return-process').then((feat) => feat.routes)
}
];
Route Hierarchy
/returns
├── '' → ReturnSearchMainComponent (search page)
├── receipts → ReturnSearchResultComponent (results list)
├── receipt/:id → Lazy-loaded return-details feature
└── process/:id → Lazy-loaded return-process feature
Navigation Patterns
From Search to Results
// Automatic navigation based on result count
onSearchCb = ({ data }) => {
if (data) {
if (data.result.length === 1) {
// Direct navigation to receipt details
return this.navigate(['receipt', data.result[0].id]);
}
// Multiple results - show list
return this.navigate(['receipts']);
}
};
From Results to Receipt Details
<!-- User clicks on result item -->
<a [routerLink]="['../', 'receipt', item.id]">
<oms-feature-return-search-result-item [item]="item">
</a>
Query Parameter Preservation
All navigation preserves filter state:
navigate(path: (string | number)[]) {
this.#router.navigate(path, {
relativeTo: this.#route,
queryParams: this._filterService.queryParams() // Preserves filters
});
}
Scroll Position Restoration
The results component uses scroll restoration:
// Route data enables restoration
{
path: 'receipts',
component: ReturnSearchResultComponent,
data: { scrollPositionRestoration: true }
}
// Component restores position after view init
ngAfterViewInit(): void {
this.restoreScrollPosition();
}
Usage Examples
Basic Search Integration
import { Component } from '@angular/core';
@Component({
selector: 'app-returns-page',
template: `
<router-outlet></router-outlet>
`
})
export class ReturnsPageComponent {}
// In app routing:
const routes: Routes = [
{
path: 'returns',
component: ReturnsPageComponent,
loadChildren: () =>
import('@isa/oms/feature/return-search').then((m) => m.routes)
}
];
Triggering Search Programmatically
While the library handles search internally, you can navigate to it with pre-filled filters:
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-dashboard',
template: `
<button (click)="searchByEmail('customer@example.com')">
Search by Email
</button>
`
})
export class DashboardComponent {
#router = inject(Router);
searchByEmail(email: string) {
this.#router.navigate(['/returns'], {
queryParams: { qs: email } // Pre-fill search query
});
}
}
Handling Search Results
The library automatically handles navigation, but you can monitor state:
import { Component, inject, computed } from '@angular/core';
import { ReturnSearchStore, ReturnSearchStatus } from '@isa/oms/data-access';
import { injectTabId } from '@isa/core/tabs';
@Component({
selector: 'app-custom-search',
template: `
@if (isSearching()) {
<p>Searching...</p>
}
@if (resultCount() > 0) {
<p>Found {{ resultCount() }} results</p>
}
`
})
export class CustomSearchComponent {
#returnSearchStore = inject(ReturnSearchStore);
#processId = injectTabId();
entity = computed(() => {
const processId = this.#processId();
return processId ? this.#returnSearchStore.getEntity(processId) : undefined;
});
isSearching = computed(() =>
this.entity()?.status === ReturnSearchStatus.Pending
);
resultCount = computed(() =>
this.entity()?.hits ?? 0
);
}
Custom Filter Configuration
The filter system is configured via backend query settings:
// Backend returns QuerySettingsDTO
interface QuerySettingsDTO {
fields: Array<{
key: string;
label: string;
type: 'text' | 'date' | 'select';
group?: 'filter' | 'search';
options?: Array<{ value: string; label: string }>;
}>;
orderBy: Array<{
key: string;
label: string;
direction: 'asc' | 'desc';
}>;
}
// Example usage in ReturnSearchService:
export class ReturnSearchService {
querySettings(): Promise<QuerySettingsDTO> {
return this.#http.get('/api/returns/query-settings').toPromise();
}
}
Implementing Custom Result Item
You can extend the result item component:
import { Component, input, computed } from '@angular/core';
import { ReturnSearchResultItemComponent } from '@isa/oms/feature/return-search';
@Component({
selector: 'app-custom-result-item',
template: `
<oms-feature-return-search-result-item [item]="item()">
</oms-feature-return-search-result-item>
<!-- Add custom content -->
<div class="custom-badge">
{{ customStatus() }}
</div>
`,
imports: [ReturnSearchResultItemComponent]
})
export class CustomResultItemComponent {
item = input.required<ReceiptListItem>();
customStatus = computed(() => {
const returnCount = this.item()?.returnCount ?? 0;
return returnCount > 0 ? 'Has Returns' : 'No Returns';
});
}
Responsive Breakpoint Handling
The library uses responsive breakpoints for adaptive layouts:
import { Component } from '@angular/core';
import { breakpoint, Breakpoint } from '@isa/ui/layout';
@Component({
selector: 'app-return-search-custom',
template: `
@if (isMobile()) {
<!-- Mobile layout -->
<ui-icon-button (click)="toggleSort()"></ui-icon-button>
} @else {
<!-- Desktop layout -->
<filter-order-by-toolbar></filter-order-by-toolbar>
}
`
})
export class ReturnSearchCustomComponent {
isMobile = breakpoint([Breakpoint.Tablet]);
}
Infinite Scroll Pagination
Pagination is handled automatically via viewport detection:
<!-- Pagination trigger in template -->
@if (renderPageTrigger()) {
<div (uiInViewport)="paging($event)"></div>
}
// Handling pagination
paging(inViewport: boolean) {
if (!inViewport) return;
const processId = this.processId();
if (processId) {
this.returnSearchStore.search({
processId,
clear: false, // Append results
query: this.#filterService.query()
});
}
}
Empty State Customization
The library provides a default empty state, but you can customize it:
@if (entityItems().length === 0 && !isSearching()) {
<ui-empty-state
title="Keine Rückgaben gefunden"
description="Versuchen Sie es mit einer anderen Suchbegriff."
>
<button (click)="clearFilters()">Filter zurücksetzen</button>
</ui-empty-state>
}
State Management
ReturnSearchStore
The library uses ReturnSearchStore from @isa/oms/data-access for state management:
interface ReturnSearchEntity {
processId: string;
status: ReturnSearchStatus;
query: Record<string, any>;
items: ReceiptListItem[];
hits: number;
page: number;
error?: string;
}
// Store methods
class ReturnSearchStore {
// Search for receipts
search(params: {
processId: string;
query: Record<string, any>;
clear: boolean;
cb?: (result: CallbackResult) => void;
}): void;
// Get entity by process ID
getEntity(processId: string): ReturnSearchEntity | undefined;
// Clear entity
clearEntity(processId: string): void;
}
State Flow
User Action → Filter Service → Search Store → Backend API
↓
Store Update
↓
Component Re-render
↓
Navigation Decision
Process ID Scoping
Each browser tab maintains its own search state:
// Tab 1: processId = "tab-123"
// Search results stored in: entities['tab-123']
// Tab 2: processId = "tab-456"
// Search results stored in: entities['tab-456']
// No collision between tabs
Query State Synchronization
Filter state is synchronized with URL query parameters:
URL: /returns/receipts?qs=john&date=2024-01-01&status=open
Filter State:
{
qs: 'john',
date: '2024-01-01',
status: 'open'
}
Benefits:
- Shareable search URLs
- Browser back/forward navigation
- Tab refresh preserves search
- Bookmark support
Filter Integration
Filter System Architecture
The library integrates with @isa/shared/filter:
// Provider setup in root component
providers: [
provideFilter(
withQuerySettingsFactory(querySettingsFactory), // Load config
withQueryParamsSync() // Sync with URL
)
]
// Resolver loads configuration
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
}
Filter Input Types
The filter system supports multiple input types:
interface FilterInput {
key: string; // Query parameter key
label: string; // Display label
type: FilterInputType;
group?: 'search' | 'filter';
options?: FilterOption[];
}
type FilterInputType =
| 'text' // Text input
| 'date' // Date picker
| 'dateRange' // Date range picker
| 'select' // Dropdown
| 'multiSelect' // Multi-select dropdown
| 'checkbox' // Checkbox group
| 'radio'; // Radio group
Search Bar Integration
The search bar is configured with a specific input key:
<filter-search-bar-input
inputKey="qs" <!-- Query string parameter -->
(triggerSearch)="onSearch()" <!-- Search trigger -->
[appearance]="'results'" <!-- Visual style -->
></filter-search-bar-input>
Query Parameter Mapping:
URL: /returns?qs=john+doe
↓
Filter State: { qs: 'john doe' }
↓
Search Query: { qs: 'john doe' }
Filter Menu Button
Dynamic filter buttons are generated from query settings:
// Component extracts filter-group inputs
filterInputs = computed(() =>
this._filterService.inputs().filter(input => input.group === 'filter')
);
<!-- Render filter buttons -->
@for (filterInput of filterInputs(); track filterInput.key) {
<filter-input-menu-button
[filterInput]="filterInput"
(applied)="onSearch()"
></filter-input-menu-button>
}
Order-By (Sort) Integration
Sorting is handled by the order-by toolbar:
<!-- Desktop: Always visible -->
<filter-order-by-toolbar
(toggled)="search()"
></filter-order-by-toolbar>
<!-- Mobile: Toggle visibility -->
@if (mobileBreakpoint() && showOrderByToolbarMobile()) {
<filter-order-by-toolbar
(toggled)="search()"
></filter-order-by-toolbar>
}
Sort Query Parameters:
URL: /returns/receipts?orderBy=receiptDate&direction=desc
Sort State:
{
orderBy: 'receiptDate',
direction: 'desc'
}
Commit Pattern
Filters use a commit pattern to control when changes are applied:
onSearch() {
// 1. Commit pending filter changes
this._filterService.commit();
// 2. Get committed query
const query = this._filterService.query();
// 3. Execute search
this._returnSearchStore.search({ processId, query, clear: true });
}
Benefits:
- User can modify multiple filters
- Changes don't trigger search until committed
- Rollback support for filter menu
Responsive Design
Breakpoint Strategy
The library uses @isa/ui/layout breakpoints:
import { breakpoint, Breakpoint } from '@isa/ui/layout';
// Mobile breakpoint (tablet and below)
mobileBreakpoint = breakpoint([Breakpoint.Tablet]);
Breakpoint Definitions:
Tablet:(max-width: 1279px)- Mobile and tabletDesktop:(min-width: 1280px) and (max-width: 1439px)- Standard desktopDesktopL:(min-width: 1440px) and (max-width: 1919px)- Large desktopDesktopXL:(min-width: 1920px)- Extra large desktop
Mobile Adaptations
Sort Toolbar Toggle
<!-- Mobile: Icon button to toggle toolbar -->
@if (mobileBreakpoint()) {
<ui-icon-button
(click)="showOrderByToolbarMobile.set(!showOrderByToolbarMobile())"
[class.active]="showOrderByToolbarMobile()"
name="isaActionSort"
></ui-icon-button>
}
<!-- Mobile: Collapsible toolbar -->
@if (mobileBreakpoint() && showOrderByToolbarMobile()) {
<filter-order-by-toolbar></filter-order-by-toolbar>
}
<!-- Desktop: Always visible inline toolbar -->
@else {
<filter-order-by-toolbar></filter-order-by-toolbar>
}
Responsive Grid Layout
The main search component uses responsive grids:
:host {
@apply w-full
grid gap-16 grid-flow-row justify-center
desktop:grid-cols-[1fr,auto] desktop:gap-6;
}
Mobile: Single column, vertical flow, 16px gap Desktop: Two-column layout, 6px gap
Tailwind Responsive Classes
The library uses Tailwind CSS responsive utilities:
<!-- Gap changes by breakpoint -->
<div class="flex flex-col gap-5 isa-desktop:gap-6">
<!-- Grid layout changes -->
<div class="grid-flow-row desktop:grid-cols-[1fr,auto]">
Deferred Rendering
List items use Angular's @defer for performance:
@for (item of entityItems(); track item.id) {
@defer (on viewport) {
<oms-feature-return-search-result-item [item]="item">
} @placeholder {
<ui-icon-button [pending]="true"></ui-icon-button>
}
}
Benefits:
- Only renders items when in viewport
- Reduces initial render time
- Improves scroll performance
- Shows loading placeholder
Testing
The library uses Jest with Spectator for testing.
Running Tests
# Run tests for this library
npx nx test oms-feature-return-search --skip-nx-cache
# Run tests with coverage
npx nx test oms-feature-return-search --code-coverage --skip-nx-cache
# Run tests in watch mode
npx nx test oms-feature-return-search --watch
Test Structure
Tests cover:
- Component rendering - Verifies components render correctly
- Signal computations - Tests computed signal logic
- User interactions - Tests button clicks, form inputs, navigation
- Empty states - Tests no-data scenarios
- Responsive behavior - Tests breakpoint-dependent rendering
- Navigation logic - Tests automatic navigation based on result count
- Filter integration - Tests filter service interaction
Example Test
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ReturnSearchResultItemComponent } from './return-search-result-item.component';
import { ReceiptListItem } from '@isa/oms/data-access';
describe('ReturnSearchResultItemComponent', () => {
let spectator: Spectator<ReturnSearchResultItemComponent>;
const createComponent = createComponentFactory(
ReturnSearchResultItemComponent
);
const mockItem: ReceiptListItem = {
billing: {
person: { firstName: 'John', lastName: 'Doe' },
address: { zipCode: '12345', city: 'Berlin' },
communicationDetails: { email: 'john@example.com' }
},
receiptNumber: 'R-12345',
orderNumber: 'O-67890',
printedDate: '2024-01-15T10:00:00Z'
} as ReceiptListItem;
it('should render receipt information', () => {
spectator = createComponent({ props: { item: mockItem } });
expect(spectator.component.receiptNumber()).toBe('R-12345');
expect(spectator.component.orderNumber()).toBe('O-67890');
expect(spectator.component.email()).toBe('john@example.com');
});
it('should format address from billing', () => {
spectator = createComponent({ props: { item: mockItem } });
expect(spectator.component.address()).toBe('12345 Berlin');
});
it('should fallback to shipping address', () => {
const item = {
...mockItem,
billing: { ...mockItem.billing, address: undefined },
shipping: { address: { zipCode: '54321', city: 'Munich' } }
} as ReceiptListItem;
spectator = createComponent({ props: { item } });
expect(spectator.component.address()).toBe('54321 Munich');
});
});
E2E Testing Attributes
The library includes E2E testing attributes:
<!-- Search input -->
<filter-search-bar-input data-what="search-input">
<!-- Result count -->
<span data-what="result-count">{{ entityHits() }} Einträge</span>
<!-- Result item -->
<ui-client-row
data-what="search-result-item"
[attr.data-which]="receiptNumber()"
>
<!-- Loading indicators -->
<ui-icon-button
data-what="load-spinner"
data-which="search"
>
<!-- Sort button (mobile) -->
<ui-icon-button data-what="sort-button-mobile">
E2E Test Example:
// Find and interact with search
await page.locator('[data-what="search-input"]').fill('john doe');
await page.locator('[data-what="search-input"]').press('Enter');
// Verify results
const resultCount = await page.locator('[data-what="result-count"]').textContent();
expect(resultCount).toContain('Einträge');
// Click first result
await page.locator('[data-what="search-result-item"]').first().click();
Architecture Notes
Current Architecture
The library follows a feature-based architecture:
ReturnSearchComponent (Root)
↓ provides Filter Context
├─→ ReturnSearchMainComponent (Search Page)
│ ├─→ FilterService (injected)
│ ├─→ ReturnSearchStore (injected)
│ └─→ ReturnTaskListComponent (shared)
│
└─→ ReturnSearchResultComponent (Results Page)
├─→ FilterService (injected)
├─→ ReturnSearchStore (injected)
└─→ ReturnSearchResultItemComponent (list items)
Dependency Flow
Feature Components
↓
Filter Service (shared/filter)
↓
ReturnSearchStore (oms/data-access)
↓
ReturnSearchService (oms/data-access)
↓
OMS API Client (generated/swagger)
State Management Pattern
The library uses NgRx Signals with entity-based state:
// Store structure (conceptual)
{
entities: {
'tab-123': {
processId: 'tab-123',
status: 'success',
items: [...],
hits: 42,
page: 1
},
'tab-456': {
processId: 'tab-456',
status: 'pending',
items: [],
hits: 0,
page: 0
}
}
}
Benefits:
- Multi-tab support
- Automatic entity cleanup
- Reactive updates via signals
- Session persistence (if configured)
Filter Provider Pattern
The library uses Angular's hierarchical dependency injection for filters:
// Root component provides filter context
@Component({
providers: [
provideFilter(
withQuerySettingsFactory(querySettingsFactory),
withQueryParamsSync()
)
]
})
export class ReturnSearchComponent {}
// Child components inject FilterService
export class ReturnSearchMainComponent {
#filterService = inject(FilterService); // Same instance
}
export class ReturnSearchResultComponent {
#filterService = inject(FilterService); // Same instance
}
Navigation Strategy
The library uses intelligent navigation based on result count:
// Decision tree
searchResults.length === 1
? navigate(['receipt', results[0].id]) // Direct to details
: navigate(['receipts']) // Show list
Benefits:
- Faster workflow for single results
- No extra click required
- Better user experience
- Consistent behavior
Known Architectural Considerations
1. TODO: Extract Search Logic to Provider (Medium Priority)
Current State:
- Search logic embedded in component methods
- No centralized search orchestration
- Manual handling of cancel and fetching status
Proposed Refactoring:
// Create SearchProvider in FilterService
class FilterService {
search(params: SearchParams): Observable<SearchResult> {
// Centralized search orchestration
// Automatic cancel previous search
// Loading state management
}
cancelSearch(): void {
// Cancel in-flight search
}
}
// Simplified component usage
onSearch() {
this.#filterService.search({
query: this.#filterService.query()
}).subscribe(result => {
// Handle result
});
}
Impact: Reduces component complexity, improves testability
2. Skeleton Loaders for Item Placeholders (Low Priority)
Current State:
- Loading spinner for deferred item placeholders
- No visual structure hint
Proposed Enhancement:
@defer (on viewport) {
<oms-feature-return-search-result-item [item]="item">
} @placeholder {
<!-- Replace spinner with skeleton -->
<ui-skeleton-loader [layout]="'client-row'">
</ui-skeleton-loader>
}
Impact: Better perceived performance, clearer loading state
3. Scroll Restoration Configuration (Low Priority)
Current State:
- Scroll restoration hardcoded in route data
- No component-level control
Proposed Enhancement:
// Make restoration configurable
@Input() scrollRestoration: boolean = true;
ngAfterViewInit(): void {
if (this.scrollRestoration) {
this.restoreScrollPosition();
}
}
Impact: More flexible for different use cases
Performance Considerations
- Deferred Rendering - List items load on viewport entry
- Infinite Scroll - Pagination reduces initial load time
- Computed Signals - Efficient reactive updates
- Track By - Angular change detection optimization
- OnPush Change Detection - All components use OnPush
- Lazy Loaded Routes - Receipt details and process routes lazy loaded
Future Enhancements
Potential improvements identified:
- Search Debouncing - Debounce search input to reduce API calls
- Result Caching - Cache search results per query
- Advanced Filtering - Multi-select, date range, numeric range filters
- Export Results - Export search results to CSV/Excel
- Saved Searches - Allow users to save common searches
- Search History - Show recent searches
- Keyboard Navigation - Arrow keys to navigate results
- Bulk Actions - Select multiple results for bulk operations
Dependencies
Required Libraries
@angular/core- Angular framework (v20.1.2)@angular/common- Common Angular utilities@angular/router- Angular routing@isa/oms/data-access- OMS data access layer withReturnSearchStoreandReturnSearchService@isa/oms/shared/task-list- Return task list component@isa/shared/filter- Filter system with search bar and filter inputs@isa/ui/buttons- Button components@isa/ui/empty-state- Empty state component@isa/ui/item-rows- Client row and item row data components@isa/ui/layout- Layout utilities with breakpoint service@isa/ui/tooltip- Tooltip components@isa/core/tabs- Tab ID management@isa/utils/scroll-position- Scroll position restoration utility@isa/icons- Icon library@ng-icons/core- Icon provider@generated/swagger/oms-api- Generated OMS API client
Development Dependencies
@nx/jest- Jest test executor@ngneat/spectator- Testing utilitiesjest- Test frameworktypescript- TypeScript compiler
Path Alias
Import from: @isa/oms/feature/return-search
Example:
import { routes } from '@isa/oms/feature/return-search';
License
Internal ISA Frontend library - not for external distribution.