Files
Lorenz Hilpert 68f50b911d Merged PR 1991: feat(navigation): implement title management and enhance tab system
 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
2025-12-02 12:38:28 +00:00
..

@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

  • 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),
  },
];
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:

  1. User enters search term (receipt number, email, or customer name)
  2. User clicks search or presses Enter
  3. If exactly 1 result: Navigate directly to receipt details
  4. If multiple results: Display results list with filters and sorting
  5. User clicks result: Navigate to receipt details

Core Concepts

Search Process

The return search follows a multi-step process managed by the ReturnSearchStore:

  1. Query Building - Filter inputs are collected and combined into a search query
  2. Query Execution - The query is sent to the backend via ReturnSearchService
  3. Result Handling - Results are stored in the NgRx Signals store per process ID
  4. Navigation Decision - Single results trigger automatic navigation
  5. 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 receipts list
  • 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:

  1. Router activates route
  2. Resolver fetches query settings via ReturnSearchService.querySettings()
  3. Query settings passed to component via ActivatedRoute.snapshot.data
  4. 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 tablet
  • Desktop: (min-width: 1280px) and (max-width: 1439px) - Standard desktop
  • DesktopL: (min-width: 1440px) and (max-width: 1919px) - Large desktop
  • DesktopXL: (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

  1. Deferred Rendering - List items load on viewport entry
  2. Infinite Scroll - Pagination reduces initial load time
  3. Computed Signals - Efficient reactive updates
  4. Track By - Angular change detection optimization
  5. OnPush Change Detection - All components use OnPush
  6. Lazy Loaded Routes - Receipt details and process routes lazy loaded

Future Enhancements

Potential improvements identified:

  1. Search Debouncing - Debounce search input to reduce API calls
  2. Result Caching - Cache search results per query
  3. Advanced Filtering - Multi-select, date range, numeric range filters
  4. Export Results - Export search results to CSV/Excel
  5. Saved Searches - Allow users to save common searches
  6. Search History - Show recent searches
  7. Keyboard Navigation - Arrow keys to navigate results
  8. 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 with ReturnSearchStore and ReturnSearchService
  • @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 utilities
  • jest - Test framework
  • typescript - 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.