Files
Lorenz Hilpert 1784e08ce6 chore: update project configurations to skip CI for specific libraries
Added "skip:ci" tag to multiple project configurations to prevent CI runs
for certain libraries. This change affects the following libraries:
crm-feature-customer-card-transactions, crm-feature-customer-loyalty-cards,
oms-data-access, oms-feature-return-details, oms-feature-return-process,
oms-feature-return-summary, remission-data-access, remission-feature-remission-list,
remission-feature-remission-return-receipt-details, remission-feature-remission-return-receipt-list,
remission-shared-remission-start-dialog, remission-shared-return-receipt-actions,
shared-address, shared-delivery, ui-carousel, and ui-dialog.

Also updated CI command in package.json to exclude tests with the "skip:ci" tag.
2025-11-20 17:24:35 +01:00
..
2025-09-30 14:50:01 +00:00

@isa/shared/address

Comprehensive Angular components for displaying addresses in both multi-line and inline formats with automatic country name resolution and intelligent formatting.

Overview

The Address library provides two standalone components for rendering addresses in different layouts. It automatically formats address data, handles country name lookups, and applies special rules for German addresses. The library integrates with the CRM data access layer to resolve ISO country codes to localized country names.

Table of Contents

Features

  • Two display modes - Multi-line (shared-address) and single-line (shared-inline-address) formats
  • Country name resolution - Automatic ISO 3166 Alpha-3 country code to name conversion
  • German address handling - Special rule to hide country for German addresses
  • Care of (c/o) support - Proper formatting of care-of address lines
  • Signal-based reactivity - Efficient computed properties for address line formatting
  • Optional fields - Graceful handling of missing address components
  • E2E testing attributes - Built-in data-what and data-which attributes
  • Standalone components - Modern Angular architecture with explicit imports
  • Flexible input - Accepts partial addresses with optional fields
  • Type-safe interface - TypeScript interface for address data structure

Quick Start

1. Multi-Line Address Display

import { Component } from '@angular/core';
import { AddressComponent, Address } from '@isa/shared/address';

@Component({
  selector: 'app-customer-details',
  standalone: true,
  imports: [AddressComponent],
  template: `
    <div class="address-section">
      <h3>Shipping Address</h3>
      <shared-address [address]="shippingAddress" />
    </div>
  `
})
export class CustomerDetailsComponent {
  shippingAddress: Address = {
    careOf: 'John Doe',
    street: 'Hauptstraße',
    streetNumber: '123',
    apartment: 'Apt 4B',
    zipCode: '10115',
    city: 'Berlin',
    country: 'DEU'
  };
}

2. Inline Address Display

import { Component } from '@angular/core';
import { InlineAddressComponent, Address } from '@isa/shared/address';

@Component({
  selector: 'app-order-summary',
  standalone: true,
  imports: [InlineAddressComponent],
  template: `
    <div class="order-info">
      <span class="label">Delivery to:</span>
      <shared-inline-address [address]="deliveryAddress" />
    </div>
  `
})
export class OrderSummaryComponent {
  deliveryAddress: Address = {
    street: 'Bakerstreet',
    streetNumber: '221B',
    zipCode: 'NW1 6XE',
    city: 'London',
    country: 'GBR'
  };
}

3. Partial Address Handling

import { Component } from '@angular/core';
import { AddressComponent, Address } from '@isa/shared/address';

@Component({
  selector: 'app-address-card',
  standalone: true,
  imports: [AddressComponent],
  template: `
    <shared-address [address]="partialAddress" />
  `
})
export class AddressCardComponent {
  // All fields are optional - component handles missing data gracefully
  partialAddress: Address = {
    street: 'Main Street',
    zipCode: '12345',
    city: 'Springfield'
    // Missing: streetNumber, apartment, country, etc.
  };
}

Core Concepts

Address Data Structure

The Address interface defines the structure for address data:

export interface Address {
  apartment?: string;      // Apartment/suite number
  careOf?: string;         // Care of (c/o) recipient
  city?: string;           // City name
  country?: string;        // ISO 3166 Alpha-3 country code (e.g., 'DEU', 'GBR', 'USA')
  district?: string;       // District (currently not displayed)
  info?: string;           // Additional address information
  po?: string;             // P.O. Box (currently not displayed)
  region?: string;         // Region (currently not displayed)
  state?: string;          // State (currently not displayed)
  street?: string;         // Street name
  streetNumber?: string;   // Street number
  zipCode?: string;        // ZIP/Postal code
}

All fields are optional to support partial addresses.

Multi-Line Format

The AddressComponent displays addresses in a structured multi-line format:

c/o John Doe
Hauptstraße 123 Apt 4B
Additional info
10115 Berlin

Lines displayed:

  1. Care of line: c/o {careOf} (if careOf exists)
  2. Street line: {street} {streetNumber} {apartment} (if any exist)
  3. Info line: {info} (if exists)
  4. City line: {zipCode} {city} (if any exist), optionally , {country} (if non-German)

Inline Format

The InlineAddressComponent displays addresses in a single line:

Hauptstraße 123, 10115 Berlin

Format: {street} {streetNumber}, {zipCode} {city} optionally , {country} (if non-German)

Country Name Resolution

Both components automatically resolve ISO 3166 Alpha-3 country codes to localized country names:

// Input
country: 'GBR'

// Displayed
"United Kingdom"

The resolution uses the CountryResource from @isa/crm/data-access.

German Address Special Rule

Addresses with country code 'DEU' (Germany) do not display the country name:

// German address
{
  street: 'Bahnhofstraße',
  streetNumber: '1',
  zipCode: '80335',
  city: 'München',
  country: 'DEU'
}
// Displays: "80335 München" (no "Germany")

// Non-German address
{
  street: 'Rue de Rivoli',
  streetNumber: '99',
  zipCode: '75001',
  city: 'Paris',
  country: 'FRA'
}
// Displays: "75001 Paris, France"

This improves readability for the primary market (Germany) while maintaining clarity for international addresses.

API Reference

AddressComponent

Multi-line address display component with structured formatting.

Selector: shared-address

Standalone: true

Inputs

address (optional)
  • Type: InputSignal<Address | undefined>
  • Description: Address data to display
  • Default: undefined

Template Structure

<div class="flex flex-col" data-what="address-display">
  @if (careOfLine()) {
    <div data-which="care-of-line">{{ careOfLine() }}</div>
  }
  @if (streetLine()) {
    <div data-which="street-line">{{ streetLine() }}</div>
  }
  @if (a.info) {
    <div data-which="info">{{ a.info }}</div>
  }
  @if (cityLine()) {
    <div data-which="city-line">{{ cityLine() }}</div>
  }
</div>

Computed Properties

careOfLine()
  • Type: Signal<string>
  • Returns: 'c/o {careOf}' if careOf exists, otherwise empty string
streetLine()
  • Type: Signal<string>
  • Returns: Space-separated combination of street, streetNumber, and apartment
  • Example: 'Hauptstraße 123 Apt 4B'
cityLine()
  • Type: Signal<string>
  • Returns: Zip code and city, optionally with country name if non-German
  • Example: '10115 Berlin' or '75001 Paris, France'

Example

<shared-address
  [address]="{
    careOf: 'Jane Smith',
    street: 'Main Street',
    streetNumber: '456',
    zipCode: '10001',
    city: 'New York',
    country: 'USA'
  }"
/>

<!-- Renders as:
c/o Jane Smith
Main Street 456
10001 New York, United States
-->

InlineAddressComponent

Single-line address display component for compact layouts.

Selector: shared-inline-address

Standalone: true

Inputs

address (optional)
  • Type: InputSignal<Address | undefined>
  • Description: Address data to display
  • Default: undefined

Template Structure

@if (addressLine()) {
  {{ addressLine() }}
}

Computed Properties

addressLine()
  • Type: Signal<string>
  • Returns: Comma-separated address components with country if non-German
  • Format: {street} {streetNumber}, {zipCode} {city} optionally , {country}
  • Example: 'Baker Street 221B, NW1 6XE London, United Kingdom'

Example

<shared-inline-address
  [address]="{
    street: 'Oxford Street',
    streetNumber: '100',
    zipCode: 'W1D 1LL',
    city: 'London',
    country: 'GBR'
  }"
/>

<!-- Renders as:
Oxford Street 100, W1D 1LL London, United Kingdom
-->

Address Interface

Type definition for address data structure.

export interface Address {
  apartment?: string;      // Apartment/suite number
  careOf?: string;         // Care of (c/o) recipient
  city?: string;           // City name
  country?: string;        // ISO 3166 Alpha-3 country code
  district?: string;       // District (not currently displayed)
  info?: string;           // Additional information
  po?: string;             // P.O. Box (not currently displayed)
  region?: string;         // Region (not currently displayed)
  state?: string;          // State (not currently displayed)
  street?: string;         // Street name
  streetNumber?: string;   // Street number
  zipCode?: string;        // ZIP/Postal code
}

All fields are optional to support various address formats and partial data.

Usage Examples

Customer Shipping Address

import { Component } from '@angular/core';
import { AddressComponent, Address } from '@isa/shared/address';

@Component({
  selector: 'app-customer-shipping',
  standalone: true,
  imports: [AddressComponent],
  template: `
    <div class="shipping-section">
      <h2 class="isa-text-heading-3-bold">Shipping Address</h2>
      <shared-address [address]="shippingAddress" />

      <button (click)="editAddress()">Edit Address</button>
    </div>
  `,
  styles: [`
    .shipping-section {
      padding: 1rem;
      border: 1px solid var(--isa-neutral-300);
      border-radius: 0.5rem;
    }
  `]
})
export class CustomerShippingComponent {
  shippingAddress: Address = {
    careOf: 'Maria Schmidt',
    street: 'Kurfürstendamm',
    streetNumber: '100',
    apartment: 'Wohnung 5',
    info: 'Hinterhaus, 2. Stock',
    zipCode: '10709',
    city: 'Berlin',
    country: 'DEU'
  };

  editAddress(): void {
    // Navigate to address edit form
  }
}

Order Summary with Inline Address

import { Component, input } from '@angular/core';
import { InlineAddressComponent, Address } from '@isa/shared/address';

interface Order {
  orderId: string;
  deliveryAddress: Address;
  totalAmount: number;
}

@Component({
  selector: 'app-order-summary',
  standalone: true,
  imports: [InlineAddressComponent],
  template: `
    <div class="order-summary">
      <h3>Order #{{ order().orderId }}</h3>

      <div class="order-details">
        <div class="detail-row">
          <span class="label">Delivery to:</span>
          <shared-inline-address [address]="order().deliveryAddress" />
        </div>

        <div class="detail-row">
          <span class="label">Total:</span>
          <span>{{ order().totalAmount | currency }}</span>
        </div>
      </div>
    </div>
  `
})
export class OrderSummaryComponent {
  order = input.required<Order>();
}

Address List with Both Formats

import { Component } from '@angular/core';
import { AddressComponent, InlineAddressComponent, Address } from '@isa/shared/address';
import { breakpoint, Breakpoint } from '@isa/ui/layout';

@Component({
  selector: 'app-address-list',
  standalone: true,
  imports: [AddressComponent, InlineAddressComponent],
  template: `
    <div class="address-list">
      @for (address of addresses; track address.id) {
        <div class="address-card">
          <h4>{{ address.type }}</h4>

          <!-- Desktop: Multi-line format -->
          @if (isDesktop()) {
            <shared-address [address]="address.data" />
          }

          <!-- Mobile: Inline format -->
          @else {
            <shared-inline-address [address]="address.data" />
          }

          <button (click)="selectAddress(address.id)">
            Select
          </button>
        </div>
      }
    </div>
  `
})
export class AddressListComponent {
  isDesktop = breakpoint([Breakpoint.Desktop, Breakpoint.DekstopL, Breakpoint.DekstopXL]);

  addresses = [
    {
      id: 1,
      type: 'Home',
      data: {
        street: 'Lindenstraße',
        streetNumber: '45',
        zipCode: '50674',
        city: 'Köln',
        country: 'DEU'
      }
    },
    {
      id: 2,
      type: 'Office',
      data: {
        street: 'Goethestraße',
        streetNumber: '12',
        zipCode: '60313',
        city: 'Frankfurt',
        country: 'DEU'
      }
    }
  ];

  selectAddress(id: number): void {
    console.log('Selected address:', id);
  }
}

International Addresses

import { Component } from '@angular/core';
import { AddressComponent, Address } from '@isa/shared/address';

@Component({
  selector: 'app-international-addresses',
  standalone: true,
  imports: [AddressComponent],
  template: `
    <div class="addresses-grid">
      @for (address of internationalAddresses; track $index) {
        <div class="address-card">
          <h4>{{ address.country }}</h4>
          <shared-address [address]="address" />
        </div>
      }
    </div>
  `
})
export class InternationalAddressesComponent {
  internationalAddresses: Address[] = [
    // Germany (country name hidden)
    {
      street: 'Alexanderplatz',
      streetNumber: '1',
      zipCode: '10178',
      city: 'Berlin',
      country: 'DEU'
    },
    // United Kingdom (country name shown)
    {
      street: 'Downing Street',
      streetNumber: '10',
      zipCode: 'SW1A 2AA',
      city: 'London',
      country: 'GBR'
    },
    // France (country name shown)
    {
      street: 'Champs-Élysées',
      streetNumber: '1',
      zipCode: '75008',
      city: 'Paris',
      country: 'FRA'
    },
    // USA (country name shown)
    {
      street: 'Pennsylvania Avenue NW',
      streetNumber: '1600',
      zipCode: '20500',
      city: 'Washington',
      country: 'USA'
    }
  ];
}

Invoice Address with Care Of

import { Component, signal } from '@angular/core';
import { AddressComponent, Address } from '@isa/shared/address';

@Component({
  selector: 'app-invoice-address',
  standalone: true,
  imports: [AddressComponent],
  template: `
    <div class="invoice-section">
      <h3 class="isa-text-heading-3-bold">Invoice Address</h3>

      <div class="address-container">
        <shared-address [address]="invoiceAddress()" />
      </div>

      @if (showCareOfInput()) {
        <input
          type="text"
          placeholder="Care of recipient"
          [(ngModel)]="careOfName"
          (blur)="updateCareOf()"
        />
      }
    </div>
  `
})
export class InvoiceAddressComponent {
  careOfName = '';
  showCareOfInput = signal(false);

  invoiceAddress = signal<Address>({
    street: 'Friedrichstraße',
    streetNumber: '200',
    zipCode: '10117',
    city: 'Berlin',
    country: 'DEU'
  });

  updateCareOf(): void {
    this.invoiceAddress.update(addr => ({
      ...addr,
      careOf: this.careOfName || undefined
    }));
  }
}

Address Comparison

import { Component } from '@angular/core';
import { AddressComponent, InlineAddressComponent, Address } from '@isa/shared/address';

@Component({
  selector: 'app-address-comparison',
  standalone: true,
  imports: [AddressComponent, InlineAddressComponent],
  template: `
    <div class="comparison-container">
      <div class="address-column">
        <h3>Billing Address</h3>
        <shared-address [address]="billingAddress" />
      </div>

      <div class="address-column">
        <h3>Shipping Address</h3>
        <shared-address [address]="shippingAddress" />
      </div>

      @if (!addressesMatch()) {
        <div class="warning">
          Billing and shipping addresses differ
        </div>
      }
    </div>
  `
})
export class AddressComparisonComponent {
  billingAddress: Address = {
    street: 'Hauptstraße',
    streetNumber: '100',
    zipCode: '10115',
    city: 'Berlin',
    country: 'DEU'
  };

  shippingAddress: Address = {
    careOf: 'Office Reception',
    street: 'Nebenstraße',
    streetNumber: '50',
    zipCode: '20095',
    city: 'Hamburg',
    country: 'DEU'
  };

  addressesMatch(): boolean {
    return JSON.stringify(this.billingAddress) === JSON.stringify(this.shippingAddress);
  }
}

Address Formatting Rules

Street Line Formatting

The street line combines multiple components with spaces:

// All components present
street: 'Hauptstraße'
streetNumber: '123'
apartment: 'Apt 4B'
// Result: "Hauptstraße 123 Apt 4B"

// Only street and number
street: 'Main Street'
streetNumber: '456'
// Result: "Main Street 456"

// Only street
street: 'Broadway'
// Result: "Broadway"

// No components
// Result: "" (line not displayed)

City Line Formatting

The city line combines zip code, city, and optionally country:

// German address (country hidden)
zipCode: '10115'
city: 'Berlin'
country: 'DEU'
// Result: "10115 Berlin"

// Non-German address (country shown)
zipCode: 'SW1A 1AA'
city: 'London'
country: 'GBR'
// Result: "SW1A 1AA London, United Kingdom"

// Only city
city: 'Paris'
// Result: "Paris"

// Only zip
zipCode: '75001'
// Result: "75001"

// No components
// Result: "" (line not displayed)

Inline Formatting

Inline format uses commas to separate components:

// Full address
street: 'Oxford Street'
streetNumber: '100'
zipCode: 'W1D 1LL'
city: 'London'
country: 'GBR'
// Result: "Oxford Street 100, W1D 1LL London, United Kingdom"

// Partial address
street: 'Main Street'
city: 'Springfield'
// Result: "Main Street, Springfield"

// Only street
street: 'Broadway'
// Result: "Broadway"

Country Name Resolution

The components resolve ISO 3166 Alpha-3 codes to country names:

'DEU'  'Deutschland' (or localized equivalent)
'GBR'  'United Kingdom'
'USA'  'United States'
'FRA'  'France'
'AUT'  'Austria'
'CHE'  'Switzerland'

Fallback: If country code not found in resource, displays the code itself.

Architecture Notes

Design Patterns

Computed Signal Pattern

Both components use computed signals for efficient reactive formatting:

streetLine = computed(() => {
  const a = this.address();
  if (!a) return '';

  const parts: string[] = [];
  if (a.street) parts.push(a.street);
  if (a.streetNumber) parts.push(a.streetNumber);
  if (a.apartment) parts.push(a.apartment);

  return parts.join(' ');
});

Benefits:

  • Automatic re-computation when address changes
  • Efficient - only recalculates affected computed values
  • OnPush compatible
  • No manual subscription management

Resource Integration Pattern

Uses CountryResource from CRM data access for country lookups:

#countryResource = inject(CountryResource);

#getCountryName(countryCode: string): string {
  const countries = this.#countryResource.resource.value();
  if (!countries || !Array.isArray(countries)) return countryCode;

  const country = countries.find((c) => c.isO3166_A_3 === countryCode);
  return country?.name || countryCode;
}

Benefits:

  • Centralized country data management
  • Automatic localization support
  • Fallback to code if resource unavailable

Optional Field Handling

All address fields are optional, with graceful degradation:

// Component handles undefined/null addresses
@if (address(); as a) {
  // Display logic
}

// Computed properties return empty strings for missing data
if (!a || !a.careOf) return '';

Component Separation

Two components serve different use cases:

  • AddressComponent: Structured multi-line display for detailed views
  • InlineAddressComponent: Compact single-line for lists and summaries

This separation of concerns improves:

  • Code maintainability
  • Component reusability
  • Performance (import only what you need)

E2E Testing Support

Both components include data-what and data-which attributes for automated testing:

<div class="flex flex-col" data-what="address-display">
  <div data-which="care-of-line">...</div>
  <div data-which="street-line">...</div>
  <div data-which="info">...</div>
  <div data-which="city-line">...</div>
</div>

Performance Considerations

  1. Computed Signals - Efficient change detection with minimal recalculations
  2. OnPush Compatible - Works with OnPush change detection strategy
  3. Lazy Resource Loading - Country resource loaded on demand
  4. Minimal DOM - Only renders lines with actual content

Future Enhancements

Potential improvements identified:

  1. State/Region Display - Add support for displaying state/region field
  2. P.O. Box Support - Integrate po field into formatting logic
  3. Custom Formatting - Allow custom format templates via input
  4. Address Validation - Integrate with address validation service
  5. Localization - Locale-specific address formatting rules
  6. Copy to Clipboard - Built-in copy functionality
  7. Map Integration - Optional map link for addresses

Testing

The library uses Vitest with Angular Testing Utilities for testing.

Running Tests

# Run tests for this library
npx nx test shared-address --skip-nx-cache

# Run tests with coverage
npx nx test shared-address --code-coverage --skip-nx-cache

# Run tests in watch mode
npx nx test shared-address --watch

Test Structure

The library includes unit tests covering:

  • Address rendering - Tests multi-line and inline formatting
  • Country resolution - Tests country name lookup
  • German address handling - Tests country name hiding for DEU
  • Missing fields - Tests graceful handling of partial addresses
  • E2E attributes - Tests presence of data-what and data-which attributes

Example Test

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AddressComponent, Address } from '@isa/shared/address';
import { CountryResource } from '@isa/crm/data-access';

describe('AddressComponent', () => {
  let component: AddressComponent;
  let fixture: ComponentFixture<AddressComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [AddressComponent],
      providers: [
        // Mock CountryResource
        {
          provide: CountryResource,
          useValue: {
            resource: {
              value: () => [
                { isO3166_A_3: 'DEU', name: 'Germany' },
                { isO3166_A_3: 'GBR', name: 'United Kingdom' }
              ]
            }
          }
        }
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(AddressComponent);
    component = fixture.componentInstance;
  });

  it('should display multi-line address', () => {
    const testAddress: Address = {
      street: 'Main Street',
      streetNumber: '123',
      zipCode: '12345',
      city: 'Berlin',
      country: 'DEU'
    };

    fixture.componentRef.setInput('address', testAddress);
    fixture.detectChanges();

    const element = fixture.nativeElement;
    expect(element.textContent).toContain('Main Street 123');
    expect(element.textContent).toContain('12345 Berlin');
    expect(element.textContent).not.toContain('Germany'); // Hidden for DEU
  });

  it('should display country for non-German addresses', () => {
    const testAddress: Address = {
      street: 'Oxford Street',
      streetNumber: '100',
      zipCode: 'W1D 1LL',
      city: 'London',
      country: 'GBR'
    };

    fixture.componentRef.setInput('address', testAddress);
    fixture.detectChanges();

    expect(fixture.nativeElement.textContent).toContain('United Kingdom');
  });
});

Dependencies

Required Libraries

  • @angular/core - Angular framework for components and signals
  • @isa/crm/data-access - CRM data access layer for country resource

Path Alias

Import from: @isa/shared/address

Exports

  • AddressComponent - Multi-line address display component
  • InlineAddressComponent - Single-line address display component
  • Address - TypeScript interface for address data

Best Practices

1. Use Appropriate Component

Choose the right component for your use case:

// Good - Multi-line for detail pages
<shared-address [address]="customerAddress" />

// Good - Inline for lists and summaries
<shared-inline-address [address]="deliveryAddress" />

// Avoid - Multi-line in compact layouts
<div class="compact-list">
  <shared-address [address]="addr" /> <!-- Too verbose -->
</div>

2. Provide Complete Address Data

Include all relevant address fields:

// Good - Complete address
const address: Address = {
  street: 'Hauptstraße',
  streetNumber: '100',
  zipCode: '10115',
  city: 'Berlin',
  country: 'DEU'
};

// Acceptable - Partial address (component handles gracefully)
const partialAddress: Address = {
  city: 'Berlin'
};

3. Include E2E Attributes in Parent

Add test attributes to parent containers:

<div class="address-section" data-what="customer-address-section">
  <shared-address [address]="address" />
</div>

4. Handle Missing Addresses

Always handle undefined/null addresses:

// Good - Conditional rendering
@if (customer().address) {
  <shared-address [address]="customer().address" />
} @else {
  <p>No address available</p>
}

// Avoid - Direct binding without check (component handles, but better to be explicit)
<shared-address [address]="possiblyUndefinedAddress" />

5. Use Type-Safe Address Objects

Leverage TypeScript for type safety:

import { Address } from '@isa/shared/address';

// Good - Type-safe
const address: Address = {
  street: 'Main Street',
  zipCode: '12345'
};

// Avoid - Untyped objects
const address = {
  street: 'Main Street',
  zip: '12345' // Wrong property name!
};

License

Internal ISA Frontend library - not for external distribution.