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
This commit is contained in:
Lorenz Hilpert
2025-12-02 12:38:28 +00:00
committed by Nino Righi
parent 0670dbfdb1
commit 68f50b911d
51 changed files with 3642 additions and 1148 deletions

View File

@@ -6,6 +6,7 @@ export const routes: Routes = [
{
path: '',
component: RewardCatalogComponent,
title: 'Prämienshop',
resolve: { querySettings: querySettingsResolverFn },
data: {
scrollPositionRestoration: true,

View File

@@ -6,6 +6,7 @@ import { canDeactivateTabCleanup } from '@isa/core/tabs';
export const routes: Routes = [
{
path: ':displayOrderIds',
title: 'Prämienshop - Bestellbestätigung',
providers: [
CoreCommandModule.forChild(OMS_ACTION_HANDLERS).providers ?? [],
],

View File

@@ -4,6 +4,7 @@ import { RewardShoppingCartComponent } from './reward-shopping-cart.component';
export const routes: Routes = [
{
path: '',
title: 'Prämienshop - Warenkorb',
component: RewardShoppingCartComponent,
},
];

View File

@@ -0,0 +1,600 @@
# @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:
```typescript
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:
```typescript
// 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:
```typescript
// 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
```typescript
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
```typescript
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
```typescript
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
```typescript
// 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
```typescript
// 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:
```typescript
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
```typescript
@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
```typescript
@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)
```typescript
// Only update the subtitle, keep existing document title
pageTitle = signal({ subtitle: '3 items' });
usePageTitle(this.pageTitle);
```
#### Skip Subtitle Update (Title Only)
```typescript
// Only update the title, no subtitle
pageTitle = signal({ title: 'Dashboard' });
usePageTitle(this.pageTitle);
```
#### Skip All Updates
```typescript
// Empty object - skips all updates
pageTitle = signal({});
usePageTitle(this.pageTitle);
```
#### Conditional Updates
```typescript
// 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):**
```typescript
// 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):**
```typescript
// No resolver needed - just use route config
{
path: 'dashboard',
component: DashboardComponent,
title: 'Dashboard' // Much simpler!
}
```
**For dynamic titles, use usePageTitle() instead:**
```typescript
// 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:**
```typescript
// 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:**
```typescript
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)
```typescript
@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)
```typescript
@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:
```bash
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
## Related Libraries
- `@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.

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'common',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'common',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "common-title-management",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/common/title-management/src",
"prefix": "common",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/common/title-management"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './lib/isa-title.strategy';
export * from './lib/use-page-title.function';
export * from './lib/title-management.types';

View File

@@ -0,0 +1,157 @@
import { TestBed } from '@angular/core/testing';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot } from '@angular/router';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { signal } from '@angular/core';
import { IsaTitleStrategy } from './isa-title.strategy';
import { TabService } from '@isa/core/tabs';
import { TITLE_PREFIX } from './title-prefix';
describe('IsaTitleStrategy', () => {
let strategy: IsaTitleStrategy;
let titleServiceMock: { setTitle: ReturnType<typeof vi.fn>; getTitle: ReturnType<typeof vi.fn> };
let tabServiceMock: {
activatedTabId: ReturnType<typeof signal<number | null>>;
patchTab: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Arrange - Create mocks
titleServiceMock = {
setTitle: vi.fn(),
getTitle: vi.fn().mockReturnValue(''),
};
tabServiceMock = {
activatedTabId: signal<number | null>(123),
patchTab: vi.fn(),
};
TestBed.configureTestingModule({
providers: [
IsaTitleStrategy,
{ provide: Title, useValue: titleServiceMock },
{ provide: TITLE_PREFIX, useValue: 'ISA' },
{ provide: TabService, useValue: tabServiceMock },
],
});
strategy = TestBed.inject(IsaTitleStrategy);
});
it('should be created', () => {
expect(strategy).toBeTruthy();
});
describe('updateTitle', () => {
it('should set document title with ISA prefix', () => {
// Arrange
const mockSnapshot = {
url: '/dashboard',
} as RouterStateSnapshot;
// Mock buildTitle to return a specific title
vi.spyOn(strategy, 'buildTitle').mockReturnValue('Dashboard');
// Act
strategy.updateTitle(mockSnapshot);
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
});
it('should update tab name via TabService', () => {
// Arrange
const mockSnapshot = {
url: '/search',
} as RouterStateSnapshot;
vi.spyOn(strategy, 'buildTitle').mockReturnValue('Artikelsuche');
// Act
strategy.updateTitle(mockSnapshot);
// Assert
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(123, {
name: 'Artikelsuche',
});
});
it('should use custom prefix from TITLE_PREFIX', () => {
// Arrange - Reset TestBed and configure with custom TITLE_PREFIX
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
IsaTitleStrategy,
{ provide: Title, useValue: titleServiceMock },
{ provide: TITLE_PREFIX, useValue: 'MyApp' }, // Custom prefix
{ provide: TabService, useValue: tabServiceMock },
],
});
const customStrategy = TestBed.inject(IsaTitleStrategy);
const mockSnapshot = {
url: '/settings',
} as RouterStateSnapshot;
vi.spyOn(customStrategy, 'buildTitle').mockReturnValue('Settings');
// Act
customStrategy.updateTitle(mockSnapshot);
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('MyApp - Settings');
});
it('should not update tab when activatedTabId is null', () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
const mockSnapshot = {
url: '/dashboard',
} as RouterStateSnapshot;
vi.spyOn(strategy, 'buildTitle').mockReturnValue('Dashboard');
// Act
strategy.updateTitle(mockSnapshot);
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
expect(tabServiceMock.patchTab).not.toHaveBeenCalled();
});
it('should not update anything when buildTitle returns empty string', () => {
// Arrange
const mockSnapshot = {
url: '/no-title',
} as RouterStateSnapshot;
vi.spyOn(strategy, 'buildTitle').mockReturnValue('');
// Act
strategy.updateTitle(mockSnapshot);
// Assert
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).not.toHaveBeenCalled();
});
it('should not update anything when buildTitle returns undefined', () => {
// Arrange
const mockSnapshot = {
url: '/no-title',
} as RouterStateSnapshot;
vi.spyOn(strategy, 'buildTitle').mockReturnValue(undefined as any);
// Act
strategy.updateTitle(mockSnapshot);
// Assert
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,89 @@
import { Injectable, inject, Injector } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
import { TabService } from '@isa/core/tabs';
import { TITLE_PREFIX } from './title-prefix';
/**
* Custom TitleStrategy for the ISA application that:
* 1. Automatically adds the ISA prefix from config to all page titles
* 2. Updates the TabService with the page title for tab management
* 3. Sets the browser document title
*
* @example
* ```typescript
* // In app.module.ts or app.config.ts
* import { TitleStrategy } from '@angular/router';
* import { IsaTitleStrategy } from '@isa/common/title-management';
*
* @NgModule({
* providers: [
* { provide: TitleStrategy, useClass: IsaTitleStrategy }
* ]
* })
* export class AppModule {}
* ```
*
* @example
* ```typescript
* // Usage in routes
* const routes: Routes = [
* {
* path: 'dashboard',
* component: DashboardComponent,
* title: 'Dashboard' // Will become "ISA - Dashboard"
* },
* {
* path: 'search',
* component: SearchComponent,
* title: 'Artikelsuche' // Will become "ISA - Artikelsuche"
* }
* ];
* ```
*/
@Injectable({ providedIn: 'root' })
export class IsaTitleStrategy extends TitleStrategy {
readonly #title = inject(Title);
readonly #injector = inject(Injector);
readonly #titlePrefix = inject(TITLE_PREFIX);
// Lazy injection to avoid circular dependency:
// TitleStrategy → TabService → UserStorageProvider → AuthService → Router → TitleStrategy
#getTabService() {
return this.#injector.get(TabService);
}
/**
* Updates the page title when navigation occurs.
* This method is called automatically by Angular's Router.
*
* @param snapshot - The router state snapshot containing route data
*/
override updateTitle(snapshot: RouterStateSnapshot): void {
const pageTitle = this.buildTitle(snapshot);
if (pageTitle) {
this.#updateTitleAndTab(pageTitle);
}
}
/**
* Updates both the document title and the tab name with the given page title.
*
* @param pageTitle - The page title to set (without prefix)
* @private
*/
#updateTitleAndTab(pageTitle: string): void {
const fullTitle = `${this.#titlePrefix} - ${pageTitle}`;
this.#title.setTitle(fullTitle);
const tabService = this.#getTabService();
const activeTabId = tabService.activatedTabId();
if (activeTabId !== null) {
tabService.patchTab(activeTabId, {
name: pageTitle,
});
}
}
}

View File

@@ -0,0 +1,29 @@
/**
* Input type for the `usePageTitle` helper function.
* Contains optional title and subtitle properties that can be set independently.
*/
export interface PageTitleInput {
/**
* Optional page title (without ISA prefix).
* When undefined, the document title will not be updated.
* @example
* ```typescript
* { title: 'Dashboard' }
* { title: searchTerm() ? `Search - "${searchTerm()}"` : undefined }
* ```
*/
title?: string;
/**
* Optional subtitle to display in the tab.
* When undefined, the tab subtitle will not be updated.
* Useful for status indicators, context information, or step numbers.
* @example
* ```typescript
* { subtitle: 'Active Orders' }
* { subtitle: 'Step 2 of 5' }
* { title: 'Order Details', subtitle: 'Pending' }
* ```
*/
subtitle?: string;
}

View File

@@ -0,0 +1,14 @@
import { inject, InjectionToken } from '@angular/core';
import { Config } from '@core/config';
import { z } from 'zod';
export const TITLE_PREFIX = new InjectionToken(
'isa.common.title-management.title-prefix',
{
providedIn: 'root',
factory: () => {
const config = inject(Config);
return config.get('title', z.string().default('ISA'));
},
},
);

View File

@@ -0,0 +1,255 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TitleRegistryService } from './title-registry.service';
describe('TitleRegistryService', () => {
let service: TitleRegistryService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TitleRegistryService],
});
service = TestBed.inject(TitleRegistryService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('register', () => {
it('should register and immediately execute the updater', () => {
// Arrange
const updater = vi.fn();
// Act
service.register(updater);
// Assert
expect(updater).toHaveBeenCalledOnce();
});
it('should return a unique symbol for each registration', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
// Act
const id1 = service.register(updater1);
const id2 = service.register(updater2);
// Assert
expect(id1).not.toBe(id2);
expect(typeof id1).toBe('symbol');
expect(typeof id2).toBe('symbol');
});
it('should make the last registered updater the active one', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
const updater3 = vi.fn();
const id1 = service.register(updater1);
const id2 = service.register(updater2);
const id3 = service.register(updater3);
vi.clearAllMocks();
// Act - Only the last registered should execute
service.updateIfActive(id1, updater1);
service.updateIfActive(id2, updater2);
service.updateIfActive(id3, updater3);
// Assert
expect(updater1).not.toHaveBeenCalled();
expect(updater2).not.toHaveBeenCalled();
expect(updater3).toHaveBeenCalledOnce();
});
});
describe('unregister', () => {
it('should remove the registration', () => {
// Arrange
const updater = vi.fn();
const id = service.register(updater);
vi.clearAllMocks();
// Act
service.unregister(id);
service.updateIfActive(id, updater);
// Assert - Updater should not be called after unregistration
expect(updater).not.toHaveBeenCalled();
});
it('should restore the previous registration when active one is unregistered', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
const id1 = service.register(updater1);
const id2 = service.register(updater2);
vi.clearAllMocks();
// Act - Unregister the active one (id2)
service.unregister(id2);
// Assert - updater1 should have been called to restore title
expect(updater1).toHaveBeenCalledOnce();
expect(updater2).not.toHaveBeenCalled();
});
it('should make the previous registration active after unregistering current active', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
const id1 = service.register(updater1);
const id2 = service.register(updater2);
service.unregister(id2);
vi.clearAllMocks();
// Act - id1 should now be active
service.updateIfActive(id1, updater1);
service.updateIfActive(id2, updater2);
// Assert
expect(updater1).toHaveBeenCalledOnce();
expect(updater2).not.toHaveBeenCalled();
});
it('should handle unregistering a non-active registration', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
const updater3 = vi.fn();
const id1 = service.register(updater1);
const id2 = service.register(updater2);
const id3 = service.register(updater3);
vi.clearAllMocks();
// Act - Unregister the middle one (not active)
service.unregister(id2);
// Assert - id3 should still be active, updater2 should not be called
service.updateIfActive(id3, updater3);
expect(updater2).not.toHaveBeenCalled();
expect(updater3).toHaveBeenCalledOnce();
});
it('should handle unregistering when no registrations remain', () => {
// Arrange
const updater = vi.fn();
const id = service.register(updater);
vi.clearAllMocks();
// Act
service.unregister(id);
// Assert - No errors should occur
service.updateIfActive(id, updater);
expect(updater).not.toHaveBeenCalled();
});
});
describe('updateIfActive', () => {
it('should execute updater only if it is the active registration', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
const id1 = service.register(updater1);
const id2 = service.register(updater2); // This becomes active
vi.clearAllMocks();
// Act
service.updateIfActive(id1, updater1);
service.updateIfActive(id2, updater2);
// Assert
expect(updater1).not.toHaveBeenCalled(); // Not active
expect(updater2).toHaveBeenCalledOnce(); // Active
});
it('should not execute updater for unregistered id', () => {
// Arrange
const updater = vi.fn();
const id = service.register(updater);
service.unregister(id);
vi.clearAllMocks();
// Act
service.updateIfActive(id, updater);
// Assert
expect(updater).not.toHaveBeenCalled();
});
});
describe('nested component scenario', () => {
it('should handle parent → child → back to parent flow', () => {
// Arrange - Simulate parent component
const parentUpdater = vi.fn(() => 'Parent Title');
const parentId = service.register(parentUpdater);
vi.clearAllMocks();
// Act 1 - Child component registers
const childUpdater = vi.fn(() => 'Child Title');
const childId = service.register(childUpdater);
// Assert 1 - Child is active
expect(childUpdater).toHaveBeenCalledOnce();
vi.clearAllMocks();
// Act 2 - Child component is destroyed
service.unregister(childId);
// Assert 2 - Parent title is restored
expect(parentUpdater).toHaveBeenCalledOnce();
vi.clearAllMocks();
// Act 3 - Parent updates should work
service.updateIfActive(parentId, parentUpdater);
// Assert 3 - Parent is active again
expect(parentUpdater).toHaveBeenCalledOnce();
});
it('should handle three-level nesting (grandparent → parent → child)', () => {
// Arrange
const grandparentUpdater = vi.fn();
const parentUpdater = vi.fn();
const childUpdater = vi.fn();
const grandparentId = service.register(grandparentUpdater);
const parentId = service.register(parentUpdater);
const childId = service.register(childUpdater);
vi.clearAllMocks();
// Act 1 - Verify child is active
service.updateIfActive(childId, childUpdater);
expect(childUpdater).toHaveBeenCalledOnce();
// Act 2 - Remove child, parent should become active
service.unregister(childId);
expect(parentUpdater).toHaveBeenCalledOnce();
vi.clearAllMocks();
// Act 3 - Remove parent, grandparent should become active
service.unregister(parentId);
expect(grandparentUpdater).toHaveBeenCalledOnce();
});
});
});

View File

@@ -0,0 +1,79 @@
import { Injectable } from '@angular/core';
/**
* Internal service that tracks which component is currently managing the page title.
* This ensures that in nested component hierarchies (parent/child routes),
* the deepest (most recently registered) component's title takes precedence.
*
* When a child component is destroyed, the parent component's title is automatically restored.
*
* @internal
*/
@Injectable({ providedIn: 'root' })
export class TitleRegistryService {
#registrations = new Map<symbol, () => void>();
#activeRegistration: symbol | null = null;
/**
* Register a new title updater. The most recently registered updater
* becomes the active one and will control the title.
*
* This implements a stack-like behavior where the last component to register
* (deepest in the component hierarchy) takes precedence.
*
* @param updater - Function that updates the title
* @returns A symbol that uniquely identifies this registration
*/
register(updater: () => void): symbol {
const id = Symbol('title-registration');
this.#registrations.set(id, updater);
this.#activeRegistration = id;
// Execute the updater immediately since it's now active
updater();
return id;
}
/**
* Unregister a title updater. If this was the active updater,
* the previous one (if any) becomes active and its title is restored.
*
* This ensures that when a child component is destroyed, the parent
* component's title is automatically restored.
*
* @param id - The symbol identifying the registration to remove
*/
unregister(id: symbol): void {
this.#registrations.delete(id);
// If we just unregistered the active one, activate the most recent remaining one
if (this.#activeRegistration === id) {
// Get the last registration (most recent)
const entries = Array.from(this.#registrations.entries());
if (entries.length > 0) {
const [lastId, lastUpdater] = entries[entries.length - 1];
this.#activeRegistration = lastId;
// Restore the previous component's title
lastUpdater();
} else {
this.#activeRegistration = null;
}
}
}
/**
* Execute the updater only if it's the currently active registration.
*
* This prevents inactive (parent) components from overwriting the title
* set by active (child) components.
*
* @param id - The symbol identifying the registration
* @param updater - Function to execute if this registration is active
*/
updateIfActive(id: symbol, updater: () => void): void {
if (this.#activeRegistration === id) {
updater();
}
}
}

View File

@@ -0,0 +1,746 @@
import { TestBed } from '@angular/core/testing';
import { Component, signal, computed } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePageTitle } from './use-page-title.function';
import { TabService } from '@isa/core/tabs';
import { TITLE_PREFIX } from './title-prefix';
describe('usePageTitle', () => {
let titleServiceMock: { setTitle: ReturnType<typeof vi.fn>; getTitle: ReturnType<typeof vi.fn> };
let tabServiceMock: {
activatedTabId: ReturnType<typeof signal<number | null>>;
patchTab: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Arrange - Create mocks
titleServiceMock = {
setTitle: vi.fn(),
getTitle: vi.fn().mockReturnValue(''),
};
tabServiceMock = {
activatedTabId: signal<number | null>(456),
patchTab: vi.fn(),
};
TestBed.configureTestingModule({
providers: [
{ provide: Title, useValue: titleServiceMock },
{ provide: TITLE_PREFIX, useValue: 'ISA' },
{ provide: TabService, useValue: tabServiceMock },
],
});
});
it('should set initial title on component creation', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Dashboard',
});
});
it('should update title when signal changes', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
searchTerm = signal('');
pageTitle = computed(() => {
const term = this.searchTerm();
return {
title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche',
};
});
constructor() {
usePageTitle(this.pageTitle);
}
}
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentInstance;
TestBed.flushEffects();
// Clear previous calls
vi.clearAllMocks();
// Act
component.searchTerm.set('Laptop');
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith(
'ISA - Artikelsuche - "Laptop"'
);
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Artikelsuche - "Laptop"',
});
});
it('should not update tab when activatedTabId is null', () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Profile' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Profile');
expect(tabServiceMock.patchTab).not.toHaveBeenCalled();
});
it('should use custom prefix from TITLE_PREFIX', () => {
// Arrange
TestBed.overrideProvider(TITLE_PREFIX, { useValue: 'MyApp' });
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'About' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('MyApp - About');
});
it('should handle multiple signal updates correctly', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
counter = signal(0);
pageTitle = computed(() => ({ title: `Page ${this.counter()}` }));
constructor() {
usePageTitle(this.pageTitle);
}
}
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentInstance;
TestBed.flushEffects();
// Clear initial call
vi.clearAllMocks();
// Act
component.counter.set(1);
TestBed.flushEffects();
component.counter.set(2);
TestBed.flushEffects();
component.counter.set(3);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledTimes(3);
expect(titleServiceMock.setTitle).toHaveBeenNthCalledWith(1, 'ISA - Page 1');
expect(titleServiceMock.setTitle).toHaveBeenNthCalledWith(2, 'ISA - Page 2');
expect(titleServiceMock.setTitle).toHaveBeenNthCalledWith(3, 'ISA - Page 3');
});
describe('nested components', () => {
it('should prioritize child component title over parent', () => {
// Arrange - Parent component
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create parent first
TestBed.createComponent(ParentComponent);
TestBed.flushEffects();
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
vi.clearAllMocks();
// Act - Create child (should win)
TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
// Assert - Child title should be set
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Settings');
});
it('should restore parent title when child component is destroyed', () => {
// Arrange - Parent component
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create both components
TestBed.createComponent(ParentComponent);
TestBed.flushEffects();
const childFixture = TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
vi.clearAllMocks();
// Act - Destroy child
childFixture.destroy();
// Assert - Parent title should be restored
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
});
it('should handle parent title updates when child is active', () => {
// Arrange - Parent component with mutable signal
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create both
const parentFixture = TestBed.createComponent(ParentComponent);
const parent = parentFixture.componentInstance;
TestBed.flushEffects();
TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
vi.clearAllMocks();
// Act - Parent tries to update (should be ignored while child is active)
parent.pageTitle.set({ title: 'Dashboard Updated' });
TestBed.flushEffects();
// Assert - Title should still be child's (parent update ignored)
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
});
it('should allow parent title updates after child is destroyed', () => {
// Arrange - Parent component with mutable signal
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create both
const parentFixture = TestBed.createComponent(ParentComponent);
const parent = parentFixture.componentInstance;
TestBed.flushEffects();
const childFixture = TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
// Destroy child
childFixture.destroy();
vi.clearAllMocks();
// Act - Parent updates now (should work)
parent.pageTitle.set({ title: 'Dashboard Updated' });
TestBed.flushEffects();
// Assert - Parent update should be reflected
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard Updated');
});
it('should handle three-level nesting (grandparent → parent → child)', () => {
// Arrange - Three levels of components
@Component({
standalone: true,
template: '<div>Grandparent</div>',
})
class GrandparentComponent {
pageTitle = signal({ title: 'Main' });
constructor() {
usePageTitle(this.pageTitle);
}
}
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create all three
TestBed.createComponent(GrandparentComponent);
TestBed.flushEffects();
TestBed.createComponent(ParentComponent);
TestBed.flushEffects();
const childFixture = TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
// Verify child is active
expect(titleServiceMock.setTitle).toHaveBeenLastCalledWith('ISA - Settings');
vi.clearAllMocks();
// Act - Destroy child
childFixture.destroy();
// Assert - Parent title should be restored
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
});
});
describe('subtitle', () => {
it('should set tab subtitle when provided', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Dashboard', subtitle: 'Overview' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Dashboard',
subtitle: 'Overview',
});
});
it('should not include subtitle in patchTab when not provided', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
const callArgs = tabServiceMock.patchTab.mock.calls[0];
expect(callArgs[0]).toBe(456);
expect(callArgs[1]).toEqual({ name: 'Dashboard' });
expect(callArgs[1]).not.toHaveProperty('subtitle');
});
it('should maintain subtitle across title signal changes', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
searchTerm = signal('');
pageTitle = computed(() => {
const term = this.searchTerm();
return {
title: term ? `Search - "${term}"` : 'Search',
subtitle: 'Active',
};
});
constructor() {
usePageTitle(this.pageTitle);
}
}
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentInstance;
TestBed.flushEffects();
vi.clearAllMocks();
// Act
component.searchTerm.set('Laptop');
TestBed.flushEffects();
// Assert
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Search - "Laptop"',
subtitle: 'Active',
});
});
it('should handle nested components each with different subtitles', () => {
// Arrange - Parent component
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard', subtitle: 'Main' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings', subtitle: 'Preferences' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create parent first
TestBed.createComponent(ParentComponent);
TestBed.flushEffects();
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Dashboard',
subtitle: 'Main',
});
vi.clearAllMocks();
// Act - Create child (should win)
TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
// Assert - Child subtitle should be set
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Settings',
subtitle: 'Preferences',
});
});
});
describe('optional title/subtitle', () => {
it('should skip document title update when title is undefined', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ subtitle: 'Loading' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
subtitle: 'Loading',
});
});
it('should skip tab subtitle update when subtitle is undefined', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
const callArgs = tabServiceMock.patchTab.mock.calls[0];
expect(callArgs[1]).toEqual({ name: 'Dashboard' });
expect(callArgs[1]).not.toHaveProperty('subtitle');
});
it('should handle empty object (skip all updates)', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({});
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).not.toHaveBeenCalled();
});
it('should handle dynamic changes from defined to undefined', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
showTitle = signal(true);
pageTitle = computed(() => ({
title: this.showTitle() ? 'Dashboard' : undefined,
subtitle: 'Active',
}));
constructor() {
usePageTitle(this.pageTitle);
}
}
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentInstance;
TestBed.flushEffects();
// Verify initial state
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
vi.clearAllMocks();
// Act - Hide title
component.showTitle.set(false);
TestBed.flushEffects();
// Assert - Title should not be updated, but subtitle should
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
subtitle: 'Active',
});
});
it('should handle both title and subtitle', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Orders', subtitle: '3 items' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Orders');
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Orders',
subtitle: '3 items',
});
});
it('should handle subtitle only (no title)', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
count = signal(5);
pageTitle = computed(() => ({
subtitle: `${this.count()} items`,
}));
constructor() {
usePageTitle(this.pageTitle);
}
}
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentInstance;
TestBed.flushEffects();
// Assert initial
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
subtitle: '5 items',
});
vi.clearAllMocks();
// Act - Update count
component.count.set(10);
TestBed.flushEffects();
// Assert - Only subtitle updates
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
subtitle: '10 items',
});
});
});
});

View File

@@ -0,0 +1,203 @@
import { inject, effect, Signal, DestroyRef } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { TabService } from '@isa/core/tabs';
import { PageTitleInput } from './title-management.types';
import { TitleRegistryService } from './title-registry.service';
import { TITLE_PREFIX } from './title-prefix';
/**
* Reactive helper function for managing dynamic page titles and subtitles in components.
* Uses Angular signals and effects to automatically update the document title
* and tab name/subtitle whenever the provided signal changes.
*
* This is ideal for pages where the title depends on component state, such as:
* - Search pages with query terms
* - Detail pages with item names
* - Wizard flows with step names
* - Filter pages with applied filters
* - Status indicators with changing subtitles
*
* @param titleSubtitleSignal - A signal containing optional title and subtitle
*
* @example
* ```typescript
* // Basic usage with static title
* import { Component, signal } from '@angular/core';
* import { usePageTitle } from '@isa/common/title-management';
*
* @Component({
* selector: 'app-dashboard',
* template: `<h1>Dashboard</h1>`
* })
* export class DashboardComponent {
* pageTitle = signal({ title: 'Dashboard' });
*
* constructor() {
* usePageTitle(this.pageTitle);
* }
* }
* ```
*
* @example
* ```typescript
* // Dynamic title with search term
* import { Component, signal, computed } from '@angular/core';
* import { usePageTitle } from '@isa/common/title-management';
*
* @Component({
* selector: 'app-article-search',
* template: `<input [(ngModel)]="searchTerm" />`
* })
* export class ArticleSearchComponent {
* searchTerm = signal('');
*
* pageTitle = computed(() => {
* const term = this.searchTerm();
* return {
* title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche'
* };
* });
*
* constructor() {
* usePageTitle(this.pageTitle);
* // Title updates automatically when searchTerm changes!
* }
* }
* ```
*
* @example
* ```typescript
* // Title with subtitle
* import { Component, signal, computed } from '@angular/core';
* import { usePageTitle } from '@isa/common/title-management';
*
* @Component({
* selector: 'app-order-details',
* template: `<h1>Order Details</h1>`
* })
* export class OrderDetailsComponent {
* orderId = signal('12345');
* orderStatus = signal<'pending' | 'processing' | 'complete'>('pending');
*
* statusLabels = {
* pending: 'Awaiting Payment',
* processing: 'In Progress',
* complete: 'Completed'
* };
*
* pageTitle = computed(() => ({
* title: `Order ${this.orderId()}`,
* subtitle: this.statusLabels[this.orderStatus()]
* }));
*
* constructor() {
* usePageTitle(this.pageTitle);
* }
* }
* ```
*
* @example
* ```typescript
* // Skip title when undefined (e.g., data not loaded yet)
* import { Component, signal, computed } from '@angular/core';
* import { usePageTitle } from '@isa/common/title-management';
*
* @Component({
* selector: 'app-product-details',
* template: `<h1>{{ productName() || 'Loading...' }}</h1>`
* })
* export class ProductDetailsComponent {
* productName = signal<string | null>(null);
*
* pageTitle = computed(() => {
* const name = this.productName();
* return {
* title: name ? `Product - ${name}` : undefined
* };
* });
*
* constructor() {
* usePageTitle(this.pageTitle);
* // Title only updates when productName is not null
* }
* }
* ```
*
* @example
* ```typescript
* // Nested components - child component's title automatically takes precedence
* // Parent component (route: /dashboard)
* export class DashboardComponent {
* pageTitle = signal({ title: 'Dashboard' });
* constructor() {
* usePageTitle(this.pageTitle); // "ISA - Dashboard"
* }
* }
*
* // Child component (route: /dashboard/settings)
* export class SettingsComponent {
* pageTitle = signal({ title: 'Settings' });
* constructor() {
* usePageTitle(this.pageTitle); // "ISA - Settings" (wins!)
* }
* }
* // When SettingsComponent is destroyed → "ISA - Dashboard" (automatically restored)
* ```
*/
export function usePageTitle(
titleSubtitleSignal: Signal<PageTitleInput>
): void {
const title = inject(Title);
const titlePrefix = inject(TITLE_PREFIX);
const tabService = inject(TabService);
const registry = inject(TitleRegistryService);
const destroyRef = inject(DestroyRef);
// Create the updater function that will be called by the registry
const updateTitle = () => {
const { title: pageTitle, subtitle } = titleSubtitleSignal();
// Update document title if title is defined
if (pageTitle !== undefined) {
const fullTitle = `${titlePrefix} - ${pageTitle}`;
title.setTitle(fullTitle);
}
// Update tab if activeTabId exists
const activeTabId = tabService.activatedTabId();
if (activeTabId !== null) {
// Build patch object conditionally based on what's defined
const patch: { name?: string; subtitle?: string } = {};
if (pageTitle !== undefined) {
patch.name = pageTitle;
}
if (subtitle !== undefined) {
patch.subtitle = subtitle;
}
// Only patch if we have something to update
if (Object.keys(patch).length > 0) {
tabService.patchTab(activeTabId, patch);
}
}
};
// Register with the registry (this will execute updateTitle immediately)
const registrationId = registry.register(updateTitle);
// Automatically unregister when component is destroyed
destroyRef.onDestroy(() => {
registry.unregister(registrationId);
});
// React to signal changes, but only update if this is the active registration
effect(() => {
// Access the signal to track it as a dependency
titleSubtitleSignal();
// Only update if this component is the active one
registry.updateIfActive(registrationId, updateTitle);
});
}

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,33 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default
// @ts-expect-error - Vitest reporter tuple types have complex inference issues, but config works correctly at runtime
defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/common/title-management',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: [
'default',
['junit', { outputFile: '../../../testresults/junit-common-title-management.xml' }],
],
coverage: {
reportsDirectory: '../../../coverage/libs/common/title-management',
provider: 'v8' as const,
reporter: ['text', 'cobertura'],
},
},
}));

View File

@@ -1,4 +1,4 @@
import { inject, Injectable } from '@angular/core';
import { inject, Injectable, Injector } from '@angular/core';
import { StorageProvider } from './storage-provider';
import {
ResponseArgsOfUserState,
@@ -32,9 +32,13 @@ const DEFAULT_USER_STATE_RESPONSE: ResponseArgsOfUserState = {
export class UserStorageProvider implements StorageProvider {
#logger = logger(() => ({ service: 'UserStorageProvider' }));
#userStateService = inject(UserStateService);
#authService = inject(AuthService);
#injector = inject(Injector);
#loadUserState = this.#authService.authenticated$.pipe(
#getAuthService() {
return this.#injector.get(AuthService);
}
#loadUserState = this.#getAuthService().authenticated$.pipe(
filter((authenticated) => authenticated),
switchMap(() =>
this.#userStateService.UserStateGetUserState().pipe(

View File

@@ -142,6 +142,7 @@ Each tab is stored as an NgRx entity with the following structure:
interface Tab {
id: number; // Unique identifier
name: string; // Display name
subtitle: string; // Subtitle (defaults to empty string)
createdAt: number; // Creation timestamp (ms)
activatedAt?: number; // Last activation timestamp
metadata: Record<string, unknown>; // Flexible metadata storage
@@ -295,6 +296,7 @@ Creates and adds a new tab to the store.
**Parameters:**
- `params: AddTabInput` - Tab creation parameters (validated with Zod)
- `name?: string` - Display name (default: 'Neuer Vorgang')
- `subtitle?: string` - Subtitle (default: '')
- `tags?: string[]` - Initial tags (default: [])
- `metadata?: Record<string, unknown>` - Initial metadata (default: {})
- `id?: number` - Optional ID (auto-generated if omitted)
@@ -306,6 +308,7 @@ Creates and adds a new tab to the store.
```typescript
const tab = this.#tabService.addTab({
name: 'Customer Order #1234',
subtitle: 'Pending approval',
tags: ['order', 'customer'],
metadata: { orderId: 1234, status: 'pending' }
});
@@ -337,6 +340,7 @@ Partially updates a tab's properties.
- `id: number` - Tab ID to update
- `changes: PatchTabInput` - Partial tab updates
- `name?: string` - Updated display name
- `subtitle?: string` - Updated subtitle
- `tags?: string[]` - Updated tags array
- `metadata?: Record<string, unknown>` - Metadata to merge
- `location?: TabLocationHistory` - Updated location history
@@ -345,6 +349,7 @@ Partially updates a tab's properties.
```typescript
this.#tabService.patchTab(42, {
name: 'Updated Name',
subtitle: 'New subtitle',
tags: ['new-tag'],
metadata: { additionalField: 'value' }
});

View File

@@ -100,6 +100,8 @@ export const TabSchema = z.object({
id: z.number(),
/** Display name for the tab (minimum 1 character) */
name: z.string().default('Neuer Vorgang'),
/** Subtitle for the tab */
subtitle: z.string().default(''),
/** Creation timestamp (milliseconds since epoch) */
createdAt: z.number(),
/** Last activation timestamp (optional) */
@@ -123,6 +125,8 @@ export interface Tab {
id: number;
/** Display name for the tab */
name: string;
/** Subtitle for the tab */
subtitle: string;
/** Creation timestamp */
createdAt: number;
/** Last activation timestamp */
@@ -146,6 +150,8 @@ export interface TabCreate {
id?: number;
/** Display name for the tab */
name: string;
/** Subtitle for the tab */
subtitle?: string;
/** Creation timestamp */
createdAt: number;
/** Last activation timestamp */
@@ -170,6 +176,8 @@ export const PersistedTabSchema = z
id: z.number(),
/** Tab display name */
name: z.string().default('Neuer Vorgang'),
/** Tab subtitle */
subtitle: z.string(),
/** Creation timestamp */
createdAt: z.number(),
/** Last activation timestamp */
@@ -195,6 +203,8 @@ export type TabInput = z.input<typeof TabSchema>;
export const AddTabSchema = z.object({
/** Display name for the new tab */
name: z.string().default('Neuer Vorgang'),
/** Subtitle for the new tab */
subtitle: z.string().default(''),
/** Initial tags for the tab */
tags: TabTagsSchema,
/** Initial metadata for the tab */
@@ -221,6 +231,8 @@ export const TabUpdateSchema = z
.object({
/** Updated display name */
name: z.string().min(1).optional(),
/** Updated subtitle */
subtitle: z.string().optional(),
/** Updated activation timestamp */
activatedAt: z.number().optional(),
/** Updated metadata object */

View File

@@ -63,6 +63,7 @@ export const TabService = signalStore(
const tab: Tab = {
id: parsed.id ?? store._generateId(),
name: parsed.name,
subtitle: parsed.subtitle,
createdAt: Date.now(),
activatedAt: parsed.activatedAt,
tags: parsed.tags,

View File

@@ -1,31 +1,34 @@
import { Routes } from '@angular/router';
import { ReturnSearchMainComponent } from './return-search-main/return-search-main.component';
import { ReturnSearchComponent } from './return-search.component';
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
import { ReturnSearchResultComponent } from './return-search-result/return-search-result.component';
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),
},
];
import { Routes } from '@angular/router';
import { ReturnSearchMainComponent } from './return-search-main/return-search-main.component';
import { ReturnSearchComponent } from './return-search.component';
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
import { ReturnSearchResultComponent } from './return-search-result/return-search-result.component';
export const routes: Routes = [
{
path: '',
component: ReturnSearchComponent,
resolve: { querySettings: querySettingsResolverFn },
title: 'Retoure',
children: [
{ path: '', component: ReturnSearchMainComponent },
{
path: 'receipts',
component: ReturnSearchResultComponent,
data: { scrollPositionRestoration: true },
},
],
},
{
path: 'receipt',
title: 'Retoure - Beleg',
loadChildren: () =>
import('@isa/oms/feature/return-details').then((feat) => feat.routes),
},
{
path: 'process',
title: 'Retoure - Prozess',
loadChildren: () =>
import('@isa/oms/feature/return-process').then((feat) => feat.routes),
},
];

View File

@@ -1,31 +1,33 @@
import { Routes } from '@angular/router';
import { RemissionListComponent } from './remission-list.component';
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
import { querySettingsDepartmentResolverFn } from './resolvers/query-settings-department.resolver-fn';
import { RemissionListType } from '@isa/remission/data-access';
export const routes: Routes = [
{
path: 'mandatory',
component: RemissionListComponent,
resolve: { querySettings: querySettingsResolverFn },
data: {
scrollPositionRestoration: true,
remiType: RemissionListType.Pflicht,
},
},
{
path: 'department',
component: RemissionListComponent,
resolve: { querySettings: querySettingsDepartmentResolverFn },
data: {
scrollPositionRestoration: true,
remiType: RemissionListType.Abteilung,
},
},
{
path: '',
redirectTo: 'mandatory',
pathMatch: 'full',
},
];
import { Routes } from '@angular/router';
import { RemissionListComponent } from './remission-list.component';
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
import { querySettingsDepartmentResolverFn } from './resolvers/query-settings-department.resolver-fn';
import { RemissionListType } from '@isa/remission/data-access';
export const routes: Routes = [
{
path: 'mandatory',
component: RemissionListComponent,
resolve: { querySettings: querySettingsResolverFn },
title: 'Remission - Pflicht',
data: {
scrollPositionRestoration: true,
remiType: RemissionListType.Pflicht,
},
},
{
path: 'department',
component: RemissionListComponent,
resolve: { querySettings: querySettingsDepartmentResolverFn },
title: 'Remission - Abteilung',
data: {
scrollPositionRestoration: true,
remiType: RemissionListType.Abteilung,
},
},
{
path: '',
redirectTo: 'mandatory',
pathMatch: 'full',
},
];

View File

@@ -4,12 +4,14 @@ export const routes: Routes = [
{
path: '',
component: RemissionReturnReceiptListComponent,
title: 'Remission',
data: {
scrollPositionRestoration: true,
},
},
{
path: ':returnId/:receiptId',
title: 'Remission - Beleg',
loadComponent: () =>
import('@isa/remission/feature/remission-return-receipt-details').then(
(m) => m.RemissionReturnReceiptDetailsComponent,