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

15 KiB

@isa/common/title-management

Reusable title management patterns for Angular applications with reactive updates and tab integration.

Overview

This library provides two complementary approaches for managing page titles in the ISA application:

  1. IsaTitleStrategy - A custom TitleStrategy for route-based static titles
  2. usePageTitle() - A reactive helper function for component-based dynamic titles

Both approaches automatically:

  • Add the ISA prefix from config to all titles
  • Update the TabService for multi-tab navigation
  • Set the browser document title

When to Use What

Scenario Recommended Approach
Static page title (never changes) Route configuration with IsaTitleStrategy
Dynamic title based on user input (search, filters) usePageTitle() in component
Title depends on loaded data (item name, ID) usePageTitle() in component
Wizard/multi-step flows with changing steps usePageTitle() in component
Combination of static base + dynamic suffix Both (route + usePageTitle())

Installation

This library is already installed and configured in your workspace. Import from:

import { IsaTitleStrategy, usePageTitle } from '@isa/common/title-management';

Setup

1. Configure IsaTitleStrategy in AppModule

To enable automatic title management for all routes, add the IsaTitleStrategy to your app providers:

// apps/isa-app/src/app/app.module.ts
import { TitleStrategy } from '@angular/router';
import { IsaTitleStrategy } from '@isa/common/title-management';

@NgModule({
  providers: [
    { provide: TitleStrategy, useClass: IsaTitleStrategy }
  ]
})
export class AppModule {}

Note: This replaces Angular's default TitleStrategy with our custom implementation that adds the ISA prefix and updates tabs.

Usage

Static Titles (Route Configuration)

For pages with fixed titles, simply add a title property to your route:

// In your routing module
const routes: Routes = [
  {
    path: 'dashboard',
    component: DashboardComponent,
    title: 'Dashboard' // Will become "ISA - Dashboard"
  },
  {
    path: 'artikelsuche',
    component: ArticleSearchComponent,
    title: 'Artikelsuche' // Will become "ISA - Artikelsuche"
  },
  {
    path: 'returns',
    component: ReturnsComponent,
    title: 'Rückgaben' // Will become "ISA - Rückgaben"
  }
];

The IsaTitleStrategy will automatically:

  • Add the configured prefix (default: "ISA")
  • Update the active tab name
  • Set the document title

Dynamic Titles (Component with Signals)

For pages where the title depends on component state, use the usePageTitle() helper:

Example 1: Search Page with Query Term

import { Component, signal, computed } from '@angular/core';
import { usePageTitle } from '@isa/common/title-management';

@Component({
  selector: 'app-article-search',
  standalone: true,
  template: `
    <input [(ngModel)]="searchTerm" placeholder="Search..." />
    <h1>{{ pageTitle().title }}</h1>
  `
})
export class ArticleSearchComponent {
  searchTerm = signal('');

  // Computed signal that updates when searchTerm changes
  pageTitle = computed(() => {
    const term = this.searchTerm();
    return {
      title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche'
    };
  });

  constructor() {
    // Title updates automatically when searchTerm changes!
    usePageTitle(this.pageTitle);
  }
}

Result:

  • Initial load: ISA - Artikelsuche
  • After searching "Laptop": ISA - Artikelsuche - "Laptop"
  • Tab name also updates automatically

Example 2: Detail Page with Item Name

import { Component, signal, computed, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { usePageTitle } from '@isa/common/title-management';

@Component({
  selector: 'app-product-details',
  standalone: true,
  template: `<h1>{{ productName() || 'Loading...' }}</h1>`
})
export class ProductDetailsComponent implements OnInit {
  private route = inject(ActivatedRoute);

  productName = signal<string | null>(null);

  pageTitle = computed(() => {
    const name = this.productName();
    return {
      title: name ? `Produkt - ${name}` : 'Produkt Details'
    };
  });

  constructor() {
    usePageTitle(this.pageTitle);
  }

  ngOnInit() {
    // Load product data...
    this.productName.set('Samsung Galaxy S24');
  }
}

Example 3: Combining Route Title with Dynamic Content

import { Component, signal, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { usePageTitle } from '@isa/common/title-management';

@Component({
  selector: 'app-order-wizard',
  standalone: true,
  template: `<div>Step {{ currentStep() }} of 3</div>`
})
export class OrderWizardComponent {
  private route = inject(ActivatedRoute);

  currentStep = signal(1);

  pageTitle = computed(() => {
    // Get base title from route config
    const baseTitle = this.route.snapshot.title || 'Bestellung';
    const step = this.currentStep();
    return {
      title: `${baseTitle} - Schritt ${step}/3`
    };
  });

  constructor() {
    usePageTitle(this.pageTitle);
  }
}

Nested Components (Parent/Child Routes)

When using usePageTitle() in nested component hierarchies (parent/child routes), the deepest component automatically wins. When the child component is destroyed (e.g., navigating away), the parent's title is automatically restored.

This happens automatically - no configuration or depth tracking needed!

Example: Dashboard → Settings Flow

// Parent route: /dashboard
@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `<router-outlet />`
})
export class DashboardComponent {
  pageTitle = signal({ title: 'Dashboard' });

  constructor() {
    usePageTitle(this.pageTitle);
    // Sets: "ISA - Dashboard"
  }
}

// Child route: /dashboard/settings
@Component({
  selector: 'app-settings',
  standalone: true,
  template: `<h1>Settings</h1>`
})
export class SettingsComponent {
  pageTitle = signal({ title: 'Settings' });

  constructor() {
    usePageTitle(this.pageTitle);
    // Sets: "ISA - Settings" (child wins!)
  }
}

// Navigation flow:
// 1. Navigate to /dashboard → Title: "ISA - Dashboard"
// 2. Navigate to /dashboard/settings → Title: "ISA - Settings" (child takes over)
// 3. Navigate back to /dashboard → Title: "ISA - Dashboard" (parent restored automatically)

How It Works

The library uses an internal registry that tracks component creation order:

  • Last-registered (deepest) component controls the title
  • Parent components' title updates are ignored while child is active
  • Automatic cleanup via Angular's DestroyRef - when child is destroyed, parent becomes active again

Real-World Scenario

// Main page with search
@Component({...})
export class ArticleSearchComponent {
  searchTerm = signal('');

  pageTitle = computed(() => {
    const term = this.searchTerm();
    return {
      title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche'
    };
  });

  constructor() {
    usePageTitle(this.pageTitle); // "ISA - Artikelsuche"
  }
}

// Detail view (child route)
@Component({...})
export class ArticleDetailsComponent {
  articleName = signal('Samsung Galaxy S24');

  pageTitle = computed(() => ({
    title: `Artikel - ${this.articleName()}`
  }));

  constructor() {
    usePageTitle(this.pageTitle); // "ISA - Artikel - Samsung Galaxy S24" (wins!)
  }
}

// When user closes detail view → "ISA - Artikelsuche" is restored automatically

Important Notes

Works with any nesting depth - Grandparent → Parent → Child → Grandchild

No manual depth tracking - Registration order determines precedence

Automatic restoration - Parent title restored when child is destroyed

⚠️ Parent signal updates are ignored while child is active (by design!)

Tab Subtitles

You can include a subtitle in the signal to display additional context in the tab:

constructor() {
  this.pageTitle = signal({
    title: 'Dashboard',
    subtitle: 'Active Orders'
  });
  usePageTitle(this.pageTitle);
}

Use Cases for Subtitles:

  • Status indicators: "Pending", "Active", "Completed"
  • Context information: "3 items", "Last updated: 2min ago"
  • Category labels: "Customer", "Order", "Product"
  • Step indicators: "Step 2 of 5", "Review"

Example: Order Processing with Status

@Component({
  selector: 'app-order-details',
  standalone: true,
  template: `
    <h1>Order {{ orderId() }}</h1>
    <p>Status: {{ orderStatus() }}</p>
  `
})
export class OrderDetailsComponent {
  orderId = signal('12345');
  orderStatus = signal<'pending' | 'processing' | 'complete'>('pending');

  // Status labels for subtitle
  statusLabels = {
    pending: 'Awaiting Payment',
    processing: 'In Progress',
    complete: 'Completed'
  };

  pageTitle = computed(() => ({
    title: `Order ${this.orderId()}`,
    subtitle: this.statusLabels[this.orderStatus()]
  }));

  constructor() {
    // Title and subtitle both update dynamically
    usePageTitle(this.pageTitle);
  }
}

Example: Search Results with Count

@Component({
  selector: 'app-article-search',
  standalone: true,
  template: `...`
})
export class ArticleSearchComponent {
  searchTerm = signal('');
  resultCount = signal(0);

  pageTitle = computed(() => {
    const term = this.searchTerm();
    const count = this.resultCount();
    return {
      title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche',
      subtitle: `${count} Ergebnis${count === 1 ? '' : 'se'}`
    };
  });

  constructor() {
    usePageTitle(this.pageTitle);
  }
}

Optional Title and Subtitle

Both title and subtitle are optional. When a property is undefined, it will not be updated:

Skip Title Update (Subtitle Only)

// Only update the subtitle, keep existing document title
pageTitle = signal({ subtitle: '3 items' });
usePageTitle(this.pageTitle);

Skip Subtitle Update (Title Only)

// Only update the title, no subtitle
pageTitle = signal({ title: 'Dashboard' });
usePageTitle(this.pageTitle);

Skip All Updates

// Empty object - skips all updates
pageTitle = signal({});
usePageTitle(this.pageTitle);

Conditional Updates

// Skip title update when data is not loaded
pageTitle = computed(() => {
  const name = this.productName();
  return {
    title: name ? `Product - ${name}` : undefined,
    subtitle: 'Loading...'
  };
});
usePageTitle(this.pageTitle);

Migration Guide

From Resolver-Based Titles

If you're currently using a resolver for titles (e.g., resolveTitle), here's how to migrate:

Before (with resolver):

// In resolver file
export const resolveTitle: (keyOrTitle: string) => ResolveFn<string> =
  (keyOrTitle) => (route, state) => {
    const config = inject(Config);
    const title = inject(Title);
    const tabService = inject(TabService);

    const titleFromConfig = config.get(`process.titles.${keyOrTitle}`, z.string().default(keyOrTitle));
    // ... manual title setting logic
    return titleFromConfig;
  };

// In routing module
{
  path: 'dashboard',
  component: DashboardComponent,
  resolve: { title: resolveTitle('Dashboard') }
}

After (with IsaTitleStrategy):

// No resolver needed - just use route config
{
  path: 'dashboard',
  component: DashboardComponent,
  title: 'Dashboard' // Much simpler!
}

For dynamic titles, use usePageTitle() instead:

// In component
pageTitle = computed(() => ({ title: this.dynamicTitle() }));

constructor() {
  usePageTitle(this.pageTitle);
}

API Reference

IsaTitleStrategy

Custom TitleStrategy implementation that extends Angular's TitleStrategy.

Methods:

  • updateTitle(snapshot: RouterStateSnapshot): void - Called automatically by Angular Router

Dependencies:

  • @isa/core/config - For title prefix configuration
  • @isa/core/tabs - For tab name updates
  • @angular/platform-browser - For document title updates

Configuration:

// In app providers
{ provide: TitleStrategy, useClass: IsaTitleStrategy }

usePageTitle(titleSubtitleSignal)

Reactive helper function for managing dynamic component titles and subtitles.

Parameters:

  • titleSubtitleSignal: Signal<PageTitleInput> - A signal containing optional title and subtitle

Returns: void

Dependencies:

  • @isa/core/config - For title prefix configuration
  • @isa/core/tabs - For tab name updates
  • @angular/platform-browser - For document title updates

Example:

const pageTitle = computed(() => ({
  title: `Search - ${query()}`,
  subtitle: `${count()} results`
}));
usePageTitle(pageTitle);

PageTitleInput

Input interface for usePageTitle().

Properties:

  • title?: string - Optional page title (without ISA prefix). When undefined, document title is not updated.
  • subtitle?: string - Optional subtitle to display in the tab. When undefined, tab subtitle is not updated.

Best Practices

Do

  • Use route-based titles for static pages
  • Use usePageTitle() for dynamic content-dependent titles
  • Keep title signals computed from other signals for reactivity
  • Use descriptive, user-friendly titles
  • Combine route titles with component-level refinements for clarity
  • Use subtitles for status indicators, context info, or step numbers
  • Return undefined for title/subtitle when you want to skip updates

Don't

  • Add the "ISA" prefix manually (it's added automatically)
  • Call Title.setTitle() directly (use these utilities instead)
  • Create multiple effects updating the same title (use one computed signal)
  • Put long, complex logic in title computations (keep them simple)

Examples from ISA Codebase

Artikelsuche (Search with Term)

@Component({...})
export class ArticleSearchComponent {
  searchTerm = signal('');

  pageTitle = computed(() => {
    const term = this.searchTerm();
    return {
      title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche'
    };
  });

  constructor() {
    usePageTitle(this.pageTitle);
  }
}

Rückgabe Details (Return with ID)

@Component({...})
export class ReturnDetailsComponent {
  returnId = signal<string | null>(null);

  pageTitle = computed(() => {
    const id = this.returnId();
    return {
      title: id ? `Rückgabe - ${id}` : 'Rückgabe Details'
    };
  });

  constructor() {
    usePageTitle(this.pageTitle);
  }
}

Testing

Run tests for this library:

npx nx test common-title-management --skip-nx-cache

Architecture Notes

This library is placed in the common domain (not core) because:

  • It's a reusable utility pattern that features opt into
  • Components can function without it (unlike core infrastructure)
  • Provides patterns for solving a recurring problem (page titles)
  • Similar to other common libraries like decorators and data-access utilities
  • @isa/core/config - Configuration management
  • @isa/core/tabs - Multi-tab navigation
  • @isa/core/navigation - Navigation context preservation

Support

For issues or questions, refer to the main ISA documentation or contact the development team.