Files
ISA-Frontend/libs/core/tabs/README.md
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

37 KiB

@isa/core/tabs

A sophisticated tab management system for Angular applications providing browser-like navigation with intelligent history management, persistence, and configurable pruning strategies.

Overview

The Core Tabs library provides a comprehensive solution for managing multiple tabs with navigation history in Angular applications. Built on NgRx Signals, it offers persistent state management, intelligent history pruning, and seamless integration with Angular Router. The system supports unlimited tabs with configurable history limits, automatic browser navigation synchronization, and flexible metadata storage for domain-specific extensions.

Table of Contents

Features

  • NgRx Signals state management - Modern functional state with reactive patterns
  • Persistent tab storage - Automatic state persistence with UserStorageProvider
  • Browser navigation sync - Automatic Router event synchronization
  • Intelligent history pruning - Three strategies: oldest, balanced, smart
  • Configurable limits - Per-tab or global history size configuration
  • Zod validation - Runtime schema validation for all operations
  • Index validation - Automatic history index integrity checking
  • Forward/backward navigation - Browser-style back/forward with pruning awareness
  • Metadata system - Flexible per-tab metadata storage
  • Angular DevTools integration - Debug tab state with Redux DevTools
  • Router resolver - Automatic tab creation from route parameters
  • Injection helpers - Convenient signal-based injection functions
  • Navigate back component - Ready-to-use back button component

Quick Start

1. Import and Inject

import { Component, inject } from '@angular/core';
import { TabService, injectTab, injectTabId } from '@isa/core/tabs';

@Component({
  selector: 'app-feature',
  template: '...'
})
export class FeatureComponent {
  // Option 1: Inject full service
  #tabService = inject(TabService);

  // Option 2: Inject current tab as signal
  currentTab = injectTab();

  // Option 3: Inject current tab ID as signal
  currentTabId = injectTabId();
}

2. Create and Activate a Tab

createNewTab(): void {
  const tab = this.#tabService.addTab({
    name: 'Customer Details',
    tags: ['customer', 'active'],
    metadata: { customerId: 12345 }
  });

  this.#tabService.activateTab(tab.id);
  console.log(`Created tab ${tab.id} at ${new Date(tab.createdAt)}`);
}

3. Navigate to a Location

navigateToCustomer(customerId: number): void {
  const tabId = this.#tabService.activatedTabId();
  if (!tabId) return;

  this.#tabService.navigateToLocation(tabId, {
    timestamp: Date.now(),
    title: `Customer ${customerId}`,
    url: `/customer/${customerId}`
  });
}

4. Navigate Back/Forward

goBack(): void {
  const tabId = this.#tabService.activatedTabId();
  if (!tabId) return;

  const previousLocation = this.#tabService.navigateBack(tabId);
  if (previousLocation) {
    this.router.navigateByUrl(previousLocation.url);
  }
}

goForward(): void {
  const tabId = this.#tabService.activatedTabId();
  if (!tabId) return;

  const nextLocation = this.#tabService.navigateForward(tabId);
  if (nextLocation) {
    this.router.navigateByUrl(nextLocation.url);
  }
}

5. Setup Router Integration

import { ApplicationConfig } from '@angular/core';
import { TabNavigationService } from '@isa/core/tabs';

export const appConfig: ApplicationConfig = {
  providers: [
    // ... other providers
    {
      provide: APP_INITIALIZER,
      useFactory: (tabNav: TabNavigationService) => () => tabNav.init(),
      deps: [TabNavigationService],
      multi: true
    }
  ]
};

Core Concepts

Tab Entity Structure

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
  location: TabLocationHistory;         // Navigation history
  tags: string[];                       // Organization tags
}

Location History

Navigation history is tracked using a cursor-based system:

interface TabLocationHistory {
  current: number;                      // Index of current location (-1 for empty)
  locations: TabLocation[];             // Array of visited locations
}

interface TabLocation {
  timestamp: number;                    // Visit timestamp (ms)
  title: string;                        // Page title
  url: string;                          // Full URL
}

Example Navigation Flow:

// Initial state
{ current: -1, locations: [] }

// After navigating to /home
{ current: 0, locations: [{ timestamp: ..., title: 'Home', url: '/home' }] }

// After navigating to /products
{
  current: 1,
  locations: [
    { timestamp: ..., title: 'Home', url: '/home' },
    { timestamp: ..., title: 'Products', url: '/products' }
  ]
}

// After navigating back
{ current: 0, locations: [...] }  // Points back to /home

Tab Metadata

Metadata provides flexible per-tab storage for domain-specific data:

// Store customer context
this.#tabService.patchTabMetadata(tabId, {
  customerId: 12345,
  customerName: 'John Doe',
  viewMode: 'details'
});

// Retrieve typed metadata
import { getMetadataHelper } from '@isa/core/tabs';

const customerId = getMetadataHelper(
  tabId,
  'customerId',
  z.number(),
  this.#tabService.entityMap()
);

History Configuration Overrides:

// Override global history limits per tab
this.#tabService.patchTabMetadata(tabId, {
  maxHistorySize: 25,        // Override global limit
  maxForwardHistory: 5       // Custom forward history depth
});

Tab ID Generation

Tab IDs are automatically generated using timestamp-based generation:

// Automatic ID generation
const tab = this.#tabService.addTab({
  name: 'New Tab'
  // ID automatically generated: Date.now()
});

// Manual ID specification
const tab = this.#tabService.addTab({
  id: 42,
  name: 'Fixed ID Tab'
});

API Reference

TabService

NgRx SignalStore providing complete tab management functionality.

State Properties

entities(): Tab[]

Returns array of all tabs in the store.

Example:

const allTabs = this.#tabService.entities();
console.log(`Total tabs: ${allTabs.length}`);
entityMap(): { [id: number]: Tab }

Returns dictionary mapping tab IDs to tab objects.

Example:

const tabMap = this.#tabService.entityMap();
const tab42 = tabMap[42];
activatedTabId(): number | null

Returns ID of currently activated tab, or null.

Example:

const activeId = this.#tabService.activatedTabId();
if (activeId !== null) {
  console.log(`Active tab: ${activeId}`);
}
activatedTab(): Tab | null

Computed signal returning currently activated tab entity.

Example:

const activeTab = this.#tabService.activatedTab();
if (activeTab) {
  console.log(`Active: ${activeTab.name}`);
}

Tab Management Methods

addTab(params): Tab

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)
    • activatedAt?: number - Optional activation timestamp

Returns: Created Tab entity with generated ID and timestamps

Example:

const tab = this.#tabService.addTab({
  name: 'Customer Order #1234',
  subtitle: 'Pending approval',
  tags: ['order', 'customer'],
  metadata: { orderId: 1234, status: 'pending' }
});

console.log(`Created tab ${tab.id}`);
activateTab(id: number): void

Activates a tab by ID and updates its activation timestamp.

Parameters:

  • id: number - Tab ID to activate

Side Effects:

  • Updates activatedTabId state
  • Sets tab's activatedAt to current timestamp

Example:

this.#tabService.activateTab(42);
patchTab(id: number, changes: PatchTabInput): void

Partially updates a tab's properties.

Parameters:

  • 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

Example:

this.#tabService.patchTab(42, {
  name: 'Updated Name',
  subtitle: 'New subtitle',
  tags: ['new-tag'],
  metadata: { additionalField: 'value' }
});
patchTabMetadata(id: number, metadata: Record<string, unknown>): void

Updates tab metadata by merging with existing metadata.

Parameters:

  • id: number - Tab ID to update
  • metadata: Record<string, unknown> - Metadata to merge

Example:

// Existing metadata: { customerId: 123 }
this.#tabService.patchTabMetadata(42, {
  orderCount: 5
});
// Result: { customerId: 123, orderCount: 5 }
removeTab(id: number): void

Removes a tab from the store.

Parameters:

  • id: number - Tab ID to remove

Example:

this.#tabService.removeTab(42);

Navigation Methods

navigateToLocation(id: number, location: TabLocationInput): TabLocation | null

Navigates to a new location in tab history with intelligent pruning.

Parameters:

  • id: number - Tab ID to navigate
  • location: TabLocationInput - Location to add
    • timestamp: number - Visit timestamp (ms)
    • title: string - Page title
    • url: string - Full URL

Returns: Validated TabLocation, or null if tab not found

Behavior:

  1. Prunes forward history based on maxForwardHistory
  2. Adds new location after current position
  3. Applies history size pruning if needed
  4. Validates and corrects history index

Example:

this.#tabService.navigateToLocation(42, {
  timestamp: Date.now(),
  title: 'Product Details',
  url: '/products/123'
});
navigateBack(id: number): TabLocation | null

Navigates backward in tab history.

Parameters:

  • id: number - Tab ID to navigate

Returns: Previous location, or null if at start or tab not found

Example:

const previousLocation = this.#tabService.navigateBack(42);
if (previousLocation) {
  this.router.navigateByUrl(previousLocation.url);
}
navigateForward(id: number): TabLocation | null

Navigates forward in tab history.

Parameters:

  • id: number - Tab ID to navigate

Returns: Next location, or null if at end or tab not found

Example:

const nextLocation = this.#tabService.navigateForward(42);
if (nextLocation) {
  this.router.navigateByUrl(nextLocation.url);
}
getCurrentLocation(id: number): TabLocation | null

Gets current location with automatic index validation.

Parameters:

  • id: number - Tab ID to query

Returns: Current location, or null if none or invalid

Side Effects:

  • Automatically corrects invalid history indices
  • Logs warnings if index correction occurs (when enabled)

Example:

const currentLoc = this.#tabService.getCurrentLocation(42);
if (currentLoc) {
  console.log(`Current page: ${currentLoc.title}`);
}
updateCurrentLocation(id: number, updates: Partial<TabLocation>): TabLocation | null

Updates the current location in-place.

Parameters:

  • id: number - Tab ID to update
  • updates: Partial<TabLocation> - Properties to update

Returns: Updated location, or null if invalid

Example:

// Update current location's title
this.#tabService.updateCurrentLocation(42, {
  title: 'Updated Title'
});
clearLocationHistory(id: number): void

Clears all navigation history for a tab.

Parameters:

  • id: number - Tab ID to clear

Example:

this.#tabService.clearLocationHistory(42);
// Result: { current: -1, locations: [] }

TabNavigationService

Service that automatically synchronizes Angular Router navigation with tab history.

init(): void

Initializes automatic navigation synchronization.

Behavior:

  • Subscribes to Router NavigationEnd events
  • Updates tab location history on each navigation
  • Handles browser back/forward with pruning awareness
  • Respects URL blacklist configuration

Usage:

// In app config
{
  provide: APP_INITIALIZER,
  useFactory: (tabNav: TabNavigationService) => () => tabNav.init(),
  deps: [TabNavigationService],
  multi: true
}

syncCurrentRoute(): void

Manually syncs current route to active tab's history.

Use Cases:

  • Initial page loads
  • New tab creation
  • Manual synchronization after route changes
  • Recovery scenarios

Example:

// After creating a new tab
const tab = this.#tabService.addTab({ name: 'New Tab' });
this.#tabService.activateTab(tab.id);
this.#tabNavigationService.syncCurrentRoute();

Helper Functions

injectTab(): Signal<Tab | null>

Injects current activated tab as a signal.

Returns: Signal emitting active tab or null

Example:

export class MyComponent {
  currentTab = injectTab();

  ngOnInit() {
    effect(() => {
      const tab = this.currentTab();
      console.log(`Active tab: ${tab?.name}`);
    });
  }
}

injectTabId(): Signal<number | null>

Injects current tab ID as a signal.

Returns: Signal emitting active tab ID or null

Example:

export class MyComponent {
  currentTabId = injectTabId();

  isTabActive = computed(() => this.currentTabId() !== null);
}

getTabHelper(tabId: number, entities: Record<number, Tab>): Tab | undefined

Retrieves a tab from entity map.

Parameters:

  • tabId: number - Tab ID to retrieve
  • entities: Record<number, Tab> - Entity map from TabService

Returns: Tab entity or undefined

Example:

const tab = getTabHelper(42, this.#tabService.entityMap());

getMetadataHelper<T>(tabId, key, schema, entities): T | undefined

Retrieves typed metadata value with Zod validation.

Parameters:

  • tabId: number - Tab ID to query
  • key: string - Metadata key to retrieve
  • schema: ZodSchema<T> - Zod schema for validation
  • entities: EntityMap<Tab> - Entity map from TabService

Returns: Validated metadata value or undefined

Example:

import { z } from 'zod';

const customerId = getMetadataHelper(
  42,
  'customerId',
  z.number(),
  this.#tabService.entityMap()
);

if (customerId !== undefined) {
  console.log(`Customer ID: ${customerId}`);
}

Router Resolver

tabResolverFn: ResolveFn<Tab>

Angular Router resolver that ensures tab exists before route activation.

Behavior:

  • Extracts tabId from route parameter
  • Creates tab if it doesn't exist
  • Activates the tab
  • Returns tab entity for route data

Route Configuration:

import { Routes } from '@angular/router';
import { tabResolverFn } from '@isa/core/tabs';

export const routes: Routes = [
  {
    path: ':tabId/customer/:customerId',
    component: CustomerComponent,
    resolve: { tab: tabResolverFn }
  }
];

Component Usage:

import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

@Component({
  selector: 'app-customer',
  template: '...'
})
export class CustomerComponent {
  constructor(private route: ActivatedRoute) {
    const tab = this.route.snapshot.data['tab'];
    console.log(`Resolved tab: ${tab.name}`);
  }
}

NavigateBackButtonComponent

Ready-to-use back button component with automatic state management.

Selector: tabs-navigate-back-button

Features:

  • Automatically disables when no back history
  • Uses current tab from TabService
  • Integrates with Angular Router
  • E2E testable with data-what attribute

Usage:

import { NavigateBackButtonComponent } from '@isa/core/tabs';

@Component({
  selector: 'app-feature',
  imports: [NavigateBackButtonComponent],
  template: `
    <tabs-navigate-back-button />
  `
})
export class FeatureComponent {}

Usage Examples

Basic Tab Creation and Navigation

import { Component, inject } from '@angular/core';
import { TabService } from '@isa/core/tabs';
import { Router } from '@angular/router';

@Component({
  selector: 'app-customer-search',
  template: '...'
})
export class CustomerSearchComponent {
  #tabService = inject(TabService);
  #router = inject(Router);

  openCustomerInNewTab(customerId: number): void {
    // Create new tab with customer context
    const tab = this.#tabService.addTab({
      name: `Customer ${customerId}`,
      tags: ['customer'],
      metadata: { customerId }
    });

    // Activate the new tab
    this.#tabService.activateTab(tab.id);

    // Navigate to customer details
    this.#router.navigate([tab.id, 'customer', customerId]);
  }
}

Working with Tab Metadata

import { Component, inject, OnInit } from '@angular/core';
import { TabService, injectTabId, getMetadataHelper } from '@isa/core/tabs';
import { z } from 'zod';

const CustomerMetadataSchema = z.object({
  customerId: z.number(),
  customerName: z.string().optional(),
  viewMode: z.enum(['details', 'orders', 'history']).default('details')
});

type CustomerMetadata = z.infer<typeof CustomerMetadataSchema>;

@Component({
  selector: 'app-customer-details',
  template: '...'
})
export class CustomerDetailsComponent implements OnInit {
  #tabService = inject(TabService);
  tabId = injectTabId();

  ngOnInit() {
    const id = this.tabId();
    if (!id) return;

    // Set initial metadata
    this.#tabService.patchTabMetadata(id, {
      customerId: 12345,
      customerName: 'John Doe',
      viewMode: 'details'
    });
  }

  getCustomerMetadata(): CustomerMetadata | undefined {
    const id = this.tabId();
    if (!id) return undefined;

    return getMetadataHelper(
      id,
      'customerMetadata',
      CustomerMetadataSchema,
      this.#tabService.entityMap()
    );
  }

  switchViewMode(mode: 'details' | 'orders' | 'history'): void {
    const id = this.tabId();
    if (!id) return;

    this.#tabService.patchTabMetadata(id, { viewMode: mode });
  }
}

Custom History Configuration

import { Component, inject } from '@angular/core';
import { TabService } from '@isa/core/tabs';

@Component({
  selector: 'app-wizard',
  template: '...'
})
export class WizardComponent {
  #tabService = inject(TabService);

  createWizardTab(): void {
    const tab = this.#tabService.addTab({
      name: 'Order Wizard',
      tags: ['wizard'],
      metadata: {
        // Override global history limits for wizard
        maxHistorySize: 10,      // Keep only last 10 steps
        maxForwardHistory: 0     // Disable forward navigation (wizard-like)
      }
    });

    this.#tabService.activateTab(tab.id);
  }

  // Forward navigation disabled by maxForwardHistory: 0
  // User must complete wizard steps linearly
}

Browser-Style Navigation

import { Component, inject, computed } from '@angular/core';
import { TabService } from '@isa/core/tabs';
import { Router } from '@angular/router';

@Component({
  selector: 'app-navigation-controls',
  template: `
    <button
      [disabled]="!canGoBack()"
      (click)="goBack()"
      data-what="back-button">
      Back
    </button>
    <button
      [disabled]="!canGoForward()"
      (click)="goForward()"
      data-what="forward-button">
      Forward
    </button>
  `
})
export class NavigationControlsComponent {
  #tabService = inject(TabService);
  #router = inject(Router);

  canGoBack = computed(() => {
    const tabId = this.#tabService.activatedTabId();
    if (!tabId) return false;

    const tab = this.#tabService.entityMap()[tabId];
    return tab ? tab.location.current > 0 : false;
  });

  canGoForward = computed(() => {
    const tabId = this.#tabService.activatedTabId();
    if (!tabId) return false;

    const tab = this.#tabService.entityMap()[tabId];
    if (!tab) return false;

    return tab.location.current < tab.location.locations.length - 1;
  });

  goBack(): void {
    const tabId = this.#tabService.activatedTabId();
    if (!tabId) return;

    const previousLocation = this.#tabService.navigateBack(tabId);
    if (previousLocation) {
      this.#router.navigateByUrl(previousLocation.url);
    }
  }

  goForward(): void {
    const tabId = this.#tabService.activatedTabId();
    if (!tabId) return;

    const nextLocation = this.#tabService.navigateForward(tabId);
    if (nextLocation) {
      this.#router.navigateByUrl(nextLocation.url);
    }
  }
}

Tab Filtering and Organization

import { Component, inject, computed } from '@angular/core';
import { TabService } from '@isa/core/tabs';

@Component({
  selector: 'app-tab-manager',
  template: '...'
})
export class TabManagerComponent {
  #tabService = inject(TabService);

  // Filter tabs by tag
  customerTabs = computed(() => {
    return this.#tabService.entities().filter(tab =>
      tab.tags.includes('customer')
    );
  });

  orderTabs = computed(() => {
    return this.#tabService.entities().filter(tab =>
      tab.tags.includes('order')
    );
  });

  // Sort tabs by last activation
  recentTabs = computed(() => {
    return [...this.#tabService.entities()]
      .filter(tab => tab.activatedAt !== undefined)
      .sort((a, b) => (b.activatedAt ?? 0) - (a.activatedAt ?? 0))
      .slice(0, 5);
  });

  // Close all tabs with specific tag
  closeCustomerTabs(): void {
    this.customerTabs().forEach(tab => {
      this.#tabService.removeTab(tab.id);
    });
  }
}

History Pruning

Pruning Strategies

The library provides three intelligent pruning strategies to manage memory usage while preserving navigation functionality.

1. Oldest First Strategy

Removes oldest history entries when size limit exceeded.

Use Case: Simple applications with linear navigation

Behavior:

  • FIFO (First In, First Out) removal
  • Preserves recent navigation
  • Maintains current position

Example:

// With locations [A, B, C, D, E], current=2 (C), maxHistorySize=3
// Result: [C, D, E] with current=0 (still pointing to C)

Configuration:

import { TAB_CONFIG, createTabConfig } from '@isa/core/tabs';

{
  provide: TAB_CONFIG,
  useValue: createTabConfig({
    pruningStrategy: 'oldest',
    maxHistorySize: 50
  })
}

2. Balanced Strategy (Default)

Maintains balanced window around current position.

Use Case: Most applications with forward/backward navigation

Behavior:

  • Preserves 70% of entries before current position
  • Preserves 30% of entries after current position
  • Maintains navigation context around current location

Example:

// With current=5 in 20 locations, maxHistorySize=10
// Keeps ~7 entries before current, current entry, ~2 entries after
// Result preserves useful navigation context

Configuration:

{
  provide: TAB_CONFIG,
  useValue: createTabConfig({
    pruningStrategy: 'balanced',  // Default
    maxHistorySize: 50
  })
}

3. Smart Strategy

Intelligent pruning based on recency and proximity.

Use Case: Complex applications with varied navigation patterns

Behavior:

  • Scores each location by recency and proximity to current
  • Recent locations (< 1 hour): 100 points base
  • Medium age (< 1 day): 60 points base
  • Old locations (> 1 day): 20 points base
  • Proximity bonus: 100 - (distance_from_current * 10)
  • Keeps highest-scoring locations

Example:

// Preserves:
// - Recently visited pages (high recency score)
// - Pages near current position (high proximity score)
// - Removes old, distant entries first

Configuration:

{
  provide: TAB_CONFIG,
  useValue: createTabConfig({
    pruningStrategy: 'smart',
    maxHistorySize: 50
  })
}

Forward History Limiting

Controls maximum forward history depth when adding new locations.

Purpose: Prevents unlimited "redo" depth while maintaining reasonable navigation

Default: 10 forward entries

Behavior:

// With current=2, maxForwardHistory=2
// Before: [A, B, C, D, E, F]
//               ^
// After:  [A, B, C, D, E]
//               ^
// Preserved 2 forward entries (D, E), removed F

Configuration:

// Global configuration
{
  provide: TAB_CONFIG,
  useValue: createTabConfig({
    maxForwardHistory: 5  // Limit forward history
  })
}

// Per-tab override
this.#tabService.patchTabMetadata(tabId, {
  maxForwardHistory: 0  // Disable forward history (wizard-like)
});

Index Validation

Automatic validation and correction of history indices.

Purpose: Maintains data integrity after history modifications

Behavior:

  • Validates indices on all navigation operations
  • Corrects invalid indices automatically
  • Logs warnings when corrections occur (configurable)

Example:

// Invalid index: current=10 with only 5 locations
// Automatically corrected to: current=4 (last valid index)

// Empty history: locations=[], current=5
// Automatically corrected to: current=-1

Configuration:

{
  provide: TAB_CONFIG,
  useValue: createTabConfig({
    enableIndexValidation: true,  // Default: true
    logPruning: true              // Log correction warnings
  })
}

Configuration

TabConfig Interface

interface TabConfig {
  maxHistorySize: number;              // Max history entries (default: 50)
  maxForwardHistory: number;           // Max forward entries (default: 10)
  pruningStrategy: 'oldest' | 'balanced' | 'smart';  // Default: 'balanced'
  enableIndexValidation: boolean;      // Default: true
  logPruning: boolean;                 // Default: false
}

Global Configuration

import { ApplicationConfig } from '@angular/core';
import { TAB_CONFIG, createTabConfig } from '@isa/core/tabs';

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: TAB_CONFIG,
      useValue: createTabConfig({
        maxHistorySize: 100,
        maxForwardHistory: 20,
        pruningStrategy: 'smart',
        enableIndexValidation: true,
        logPruning: true
      })
    }
  ]
};

Per-Tab Configuration Override

// Override global limits for specific tab
this.#tabService.addTab({
  name: 'High History Tab',
  metadata: {
    maxHistorySize: 200,      // Override global limit
    maxForwardHistory: 50     // Override global limit
  }
});

// Runtime override
this.#tabService.patchTabMetadata(tabId, {
  maxHistorySize: 25,
  maxForwardHistory: 5
});

Default Configuration

const DEFAULT_TAB_CONFIG: TabConfig = {
  maxHistorySize: 50,
  maxForwardHistory: 10,
  pruningStrategy: 'balanced',
  enableIndexValidation: true,
  logPruning: false
};

Router Integration

Automatic Navigation Synchronization

The TabNavigationService automatically syncs Router events to tab history.

Setup:

import { ApplicationConfig, APP_INITIALIZER } from '@angular/core';
import { TabNavigationService } from '@isa/core/tabs';

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: (tabNav: TabNavigationService) => () => tabNav.init(),
      deps: [TabNavigationService],
      multi: true
    }
  ]
};

Behavior:

  • Listens to NavigationEnd events
  • Extracts tabId from URL patterns (/:tabId/... or /kunde/:processId/...)
  • Creates location entries automatically
  • Handles browser back/forward with pruning awareness
  • Respects URL blacklist for excluded routes

URL Patterns

The service recognizes two URL patterns:

Modern Pattern:

// URL: /42/customer/123
// Extracted tabId: 42

Legacy Pattern:

// URL: /kunde/42/details
// Extracted processId (maps to tabId): 42

Fallback:

// URL: /dashboard (no ID in URL)
// Uses currently activated tab ID

URL Blacklist

Certain URLs are excluded from history tracking:

export const HISTORY_BLACKLIST_URLS = ['/kunde/dashboard'];

Router Resolver Integration

import { Routes } from '@angular/router';
import { tabResolverFn } from '@isa/core/tabs';

export const routes: Routes = [
  {
    path: ':tabId',
    resolve: { tab: tabResolverFn },
    children: [
      {
        path: 'customer/:customerId',
        component: CustomerComponent
      },
      {
        path: 'order/:orderId',
        component: OrderComponent
      }
    ]
  }
];

Resolver Behavior:

  1. Extracts tabId from route parameter
  2. Validates tabId is positive integer
  3. Creates tab if it doesn't exist (name: 'Neuer Vorgang')
  4. Activates the tab
  5. Returns tab entity for route data access

Testing

The library uses Jest for testing.

Running Tests

# Run tests for this library
npx nx test core-tabs --skip-nx-cache

# Run tests with coverage
npx nx test core-tabs --code-coverage --skip-nx-cache

# Run tests in watch mode
npx nx test core-tabs --watch

Test Structure

The library would benefit from comprehensive unit tests covering:

  • Tab creation - Add, activate, remove operations
  • Navigation - Forward, backward, location management
  • History pruning - All three strategies with various scenarios
  • Index validation - Correction logic and edge cases
  • Metadata management - Patch, retrieve, type validation
  • Router integration - Navigation sync, blacklist, resolver
  • State persistence - Storage integration
  • Configuration - Global and per-tab overrides
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from '@jest/globals';
import { TabService } from './tab';

describe('TabService', () => {
  let service: TabService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(TabService);
  });

  it('should create tab with auto-generated ID', () => {
    const tab = service.addTab({ name: 'Test Tab' });

    expect(tab.id).toBeGreaterThan(0);
    expect(tab.name).toBe('Test Tab');
    expect(tab.location.current).toBe(-1);
    expect(tab.location.locations).toEqual([]);
  });

  it('should navigate to location', () => {
    const tab = service.addTab({ name: 'Test' });

    const location = service.navigateToLocation(tab.id, {
      timestamp: Date.now(),
      title: 'Home',
      url: '/home'
    });

    expect(location).not.toBeNull();
    expect(location?.url).toBe('/home');

    const updatedTab = service.entityMap()[tab.id];
    expect(updatedTab.location.current).toBe(0);
    expect(updatedTab.location.locations).toHaveLength(1);
  });

  it('should navigate back', () => {
    const tab = service.addTab({ name: 'Test' });

    service.navigateToLocation(tab.id, {
      timestamp: Date.now(),
      title: 'Home',
      url: '/home'
    });

    service.navigateToLocation(tab.id, {
      timestamp: Date.now(),
      title: 'Products',
      url: '/products'
    });

    const previousLocation = service.navigateBack(tab.id);

    expect(previousLocation?.url).toBe('/home');

    const updatedTab = service.entityMap()[tab.id];
    expect(updatedTab.location.current).toBe(0);
  });
});

Architecture Notes

Current Architecture

The library follows a layered architecture with NgRx Signals at its core:

Components/Features
       ↓
  Injection Helpers (injectTab, injectTabId)
       ↓
  TabNavigationService (Router sync)
       ↓
  TabService (NgRx SignalStore)
       ↓
├─→ TabHistoryPruner (history management)
├─→ Storage Integration (UserStorageProvider)
├─→ DevTools Integration (Redux DevTools)
└─→ Zod Validation (schemas)

Design Patterns

1. Signal Store Pattern

Uses NgRx Signals with functional composition:

signalStore(
  withDevtools('TabService'),
  withStorage('tabs', UserStorageProvider, { autosave: true }),
  withState({ activatedTabId: null }),
  withEntities<Tab>(),
  withProps(),
  withComputed(),
  withMethods()
)

Benefits:

  • Reactive state management
  • Automatic persistence
  • DevTools integration
  • Type-safe operations

2. Strategy Pattern (Pruning)

Three interchangeable pruning strategies:

switch (config.pruningStrategy) {
  case 'oldest': return TabHistoryPruner.pruneOldestFirst(...);
  case 'balanced': return TabHistoryPruner.pruneBalanced(...);
  case 'smart': return TabHistoryPruner.pruneSmart(...);
}

Benefits:

  • Configurable behavior
  • Easy testing
  • New strategies can be added

3. Injection Token Pattern

Dependency injection for configuration and ID generation:

export const TAB_CONFIG = new InjectionToken<TabConfig>('TAB_CONFIG', {
  providedIn: 'root',
  factory: () => DEFAULT_TAB_CONFIG
});

export const CORE_TAB_ID_GENERATOR = new InjectionToken<() => number>(
  'CORE_TAB_ID_GENERATOR',
  { providedIn: 'root', factory: () => generateTabId }
);

Benefits:

  • Easy testing with mocks
  • Runtime configuration
  • No circular dependencies

State Persistence

Automatic state persistence using withStorage():

withStorage('tabs', UserStorageProvider, { autosave: true })

Features:

  • Saves to user-scoped storage
  • Automatic save on state changes
  • Loads on service initialization
  • Validates loaded state with Zod

Storage Key: tabs (namespace for user storage)

Index Integrity

The system maintains history index integrity through:

  1. Validation on read - getCurrentLocation() validates indices
  2. Validation on navigation - Back/forward check bounds
  3. Correction on errors - Invalid indices auto-corrected
  4. Logging - Optional warnings for debugging

Example Correction:

// Corrupted state: current=10, locations.length=5
const { index, wasInvalid } = TabHistoryPruner.validateLocationIndex(
  locations,
  current
);
// index=4 (corrected to last valid), wasInvalid=true

Performance Considerations

  1. Computed Signals - activatedTab is computed, not stored
  2. Immutable Updates - Spread operators ensure immutability
  3. Pruning Efficiency - All strategies are O(n) or better
  4. Index Validation - O(1) validation, only corrects when needed
  5. Storage Optimization - Autosave debounced by library

Known Limitations

1. Tab ID Collision Risk (Low Priority)

Current State:

  • Uses Date.now() for ID generation
  • Collision possible with rapid tab creation

Proposed Solution:

  • Add sequence counter: Date.now() + (counter++)
  • Or use UUID/nanoid for guaranteed uniqueness

Impact: Low risk in practice, rapid creation uncommon

2. Storage Size Limits (Medium Priority)

Current State:

  • No limit on number of tabs
  • Storage could grow unbounded

Proposed Solutions:

  • Maximum tab limit (configurable)
  • Automatic cleanup of old inactive tabs
  • Storage quota monitoring

Impact: Medium risk for long-running sessions

3. URL Blacklist Not Configurable (Low Priority)

Current State:

  • Hardcoded blacklist in constants
  • Cannot customize at runtime

Proposed Solution:

  • Injection token for blacklist
  • Merge with default blacklist

Impact: Low, blacklist rarely needs customization

Future Enhancements

Potential improvements identified:

  1. Tab Groups - Organize tabs into collapsible groups
  2. Tab Pinning - Pin important tabs to prevent closure
  3. Tab Duplication - Clone existing tabs with history
  4. History Export - Export/import tab state for debugging
  5. Analytics - Track tab usage patterns
  6. Lazy History - Only load history when needed
  7. Tab Sorting - Custom sort orders (recent, alphabetical, manual)
  8. Search - Search across tab history locations

Dependencies

Required Libraries

  • @angular/core - Angular framework
  • @angular/router - Router integration
  • @angular/platform-browser - Title service
  • @ngrx/signals - State management
  • @ngrx/signals/entities - Entity management
  • @angular-architects/ngrx-toolkit - DevTools integration
  • @isa/core/storage - Storage providers
  • @isa/core/logging - Logging service
  • @isa/ui/buttons - Button component (for NavigateBackButtonComponent)
  • @isa/icons - Icon library (for NavigateBackButtonComponent)
  • @ng-icons/core - Icon framework (for NavigateBackButtonComponent)
  • @angular/cdk/coercion - Array coercion utilities
  • zod - Schema validation

Path Alias

Import from: @isa/core/tabs

License

Internal ISA Frontend library - not for external distribution.