feat(core-tabs): add pattern-based URL exclusion and tab deactivation guard

- Change URL blacklist from exact match to prefix matching
- Rename HISTORY_BLACKLIST_URLS to HISTORY_BLACKLIST_PATTERNS
- Add deactivateTab() method to TabService
- Add deactivateTabGuard for routes outside tab context
- Apply guard to dashboard route in app.routes.ts
- Update README with new features and API documentation

Routes like /dashboard and /kunde/dashboard now properly
deactivate the active tab when navigated to.
This commit is contained in:
Lorenz Hilpert
2025-12-11 13:54:39 +01:00
parent 43e4a6bf64
commit 33026c064f
7 changed files with 122 additions and 24 deletions

View File

@@ -25,6 +25,7 @@ import {
tabResolverFn,
processResolverFn,
hasTabIdGuard,
deactivateTabGuard,
} from '@isa/core/tabs';
export const routes: Routes = [
@@ -47,6 +48,7 @@ export const routes: Routes = [
path: 'dashboard',
loadChildren: () =>
import('@page/dashboard').then((m) => m.DashboardModule),
canActivate: [deactivateTabGuard],
data: {
matomo: {
title: 'Dashboard',

View File

@@ -32,6 +32,7 @@ The Core Tabs library provides a comprehensive solution for managing multiple ta
- **Metadata system** - Flexible per-tab metadata storage
- **Angular DevTools integration** - Debug tab state with Redux DevTools
- **Router resolver** - Automatic tab creation from route parameters
- **Tab deactivation guard** - Deactivate tabs when entering global routes
- **Injection helpers** - Convenient signal-based injection functions
- **Navigate back component** - Ready-to-use back button component
@@ -332,6 +333,20 @@ Activates a tab by ID and updates its activation timestamp.
this.#tabService.activateTab(42);
```
##### `deactivateTab(): void`
Deactivates the currently active tab by setting `activatedTabId` to null.
**Use Cases:**
- Navigating to global routes (dashboard, branch operations)
- Exiting tab context without closing the tab
**Example:**
```typescript
this.#tabService.deactivateTab();
// activatedTabId is now null
```
##### `patchTab(id: number, changes: PatchTabInput): void`
Partially updates a tab's properties.
@@ -666,6 +681,42 @@ export class CustomerComponent {
}
```
### Deactivate Tab Guard
#### `deactivateTabGuard: CanActivateFn`
Angular Router guard that deactivates the active tab when entering a route.
**Use Case:** Routes that exist outside of a process/tab context, such as dashboard pages or global branch operations.
**Behavior:**
- Sets `activatedTabId` to null
- Logs the deactivation with previous tab ID
- Always returns true (allows navigation)
**Route Configuration:**
```typescript
import { Routes } from '@angular/router';
import { deactivateTabGuard } from '@isa/core/tabs';
export const routes: Routes = [
{
path: 'dashboard',
loadChildren: () => import('./dashboard').then(m => m.DashboardModule),
canActivate: [deactivateTabGuard]
},
{
path: 'filiale/task-calendar',
loadChildren: () => import('./task-calendar').then(m => m.TaskCalendarModule),
canActivate: [deactivateTabGuard]
}
];
```
**Why use a guard instead of TabNavigationService?**
Tab activation happens in route resolvers (`tabResolverFn`), so tab deactivation should also happen in route guards for consistency. This keeps all tab activation/deactivation logic within the Angular Router lifecycle.
### NavigateBackButtonComponent
Ready-to-use back button component with automatic state management.
@@ -1209,12 +1260,18 @@ The service recognizes two URL patterns:
### URL Blacklist
Certain URLs are excluded from history tracking:
Certain URL patterns are excluded from history tracking using prefix matching:
```typescript
export const HISTORY_BLACKLIST_URLS = ['/kunde/dashboard'];
export const HISTORY_BLACKLIST_PATTERNS = ['/kunde/dashboard', '/dashboard'];
```
**Behavior:**
- Uses prefix matching (not exact match)
- `/kunde/dashboard` excludes `/kunde/dashboard`, `/kunde/dashboard/`, `/kunde/dashboard/stats`, etc.
- `/dashboard` excludes `/dashboard`, `/dashboard/overview`, etc.
- Does NOT exclude `/kunde/123/dashboard` (different prefix)
### Router Resolver Integration
```typescript
@@ -1495,18 +1552,6 @@ const { index, wasInvalid } = TabHistoryPruner.validateLocationIndex(
**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:

View File

@@ -9,3 +9,4 @@ export * from './lib/tab-config';
export * from './lib/helpers';
export * from './lib/has-tab-id.guard';
export * from './lib/tab-cleanup.guard';
export * from './lib/deactivate-tab.guard';

View File

@@ -0,0 +1,35 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { TabService } from './tab';
import { logger } from '@isa/core/logging';
/**
* Guard that deactivates the currently active tab when entering a route.
*
* Use this guard on routes that exist outside of a process/tab context,
* such as dashboard pages or global branch operations.
*
* @example
* ```typescript
* const routes: Routes = [
* {
* path: 'dashboard',
* loadChildren: () => import('./dashboard').then(m => m.DashboardModule),
* canActivate: [deactivateTabGuard],
* },
* ];
* ```
*/
export const deactivateTabGuard: CanActivateFn = () => {
const tabService = inject(TabService);
const log = logger({ guard: 'deactivateTabGuard' });
const previousTabId = tabService.activatedTabId();
if (previousTabId !== null) {
tabService.deactivateTab();
log.debug('Tab deactivated on route activation', () => ({ previousTabId }));
}
return true;
};

View File

@@ -6,16 +6,21 @@
*/
/**
* URLs that should not be added to tab navigation history.
* URL patterns that should not be added to tab navigation history.
*
* These routes are excluded to prevent cluttering the history stack with
* frequently visited pages that don't need to be tracked in tab history.
* These patterns are matched using prefix matching - any URL that starts with
* a pattern in this list will be excluded from history tracking.
*
* @example
* ```typescript
* // Dashboard routes are excluded because they serve as entry points
* // and don't represent meaningful navigation steps in a workflow
* // Dashboard routes are excluded because they don't run in a process context
* // Pattern '/kunde/dashboard' excludes:
* // - '/kunde/dashboard'
* // - '/kunde/dashboard/'
* // - '/kunde/dashboard/stats'
* // But NOT:
* // - '/kunde/123/dashboard'
* '/kunde/dashboard'
* ```
*/
export const HISTORY_BLACKLIST_URLS = ['/kunde/dashboard'];
export const HISTORY_BLACKLIST_PATTERNS = ['/kunde/dashboard', '/dashboard'];

View File

@@ -4,7 +4,7 @@ import { filter } from 'rxjs/operators';
import { TabService } from './tab';
import { TabLocation } from './schemas';
import { Title } from '@angular/platform-browser';
import { HISTORY_BLACKLIST_URLS } from './tab-navigation.constants';
import { HISTORY_BLACKLIST_PATTERNS } from './tab-navigation.constants';
/**
* Service that automatically syncs browser navigation events to tab location history.
@@ -39,7 +39,7 @@ export class TabNavigationService {
}
#syncNavigationToTab(event: NavigationEnd) {
// Skip blacklisted URLs
// Skip blacklisted URLs (tab deactivation handled by route guards)
if (this.#shouldSkipHistory(event.url)) {
return;
}
@@ -65,11 +65,14 @@ export class TabNavigationService {
/**
* Checks if a URL should be excluded from tab navigation history.
*
* @param url - The URL to check against the blacklist
* Uses prefix matching - a URL is excluded if it starts with any pattern
* in the blacklist. This allows excluding entire route trees.
*
* @param url - The URL to check against the blacklist patterns
* @returns true if the URL should be skipped, false otherwise
*/
#shouldSkipHistory(url: string): boolean {
return HISTORY_BLACKLIST_URLS.includes(url);
return HISTORY_BLACKLIST_PATTERNS.some(pattern => url.startsWith(pattern));
}
#getActiveTabId(url: string): number | null {

View File

@@ -104,6 +104,13 @@ export const TabService = signalStore(
store._logger.debug('Tab activated', () => ({ tabId: id }));
},
deactivateTab() {
const previousTabId = store.activatedTabId();
patchState(store, { activatedTabId: null });
store._logger.debug('Tab deactivated', () => ({
previousTabId,
}));
},
patchTab(id: number, changes: z.infer<typeof PatchTabSchema>) {
const currentTab = store.entityMap()[id];