Merged PR 2025: fix(core-tabs): improve tab cleanup and naming logic

fix(core-tabs): improve tab cleanup and naming logic

Refactor tab management to handle checkout state transitions more reliably:

- Extract helpers (formatCustomerTabNameHelper, checkCartHasItemsHelper,
  getNextTabNameHelper) from checkout component to @isa/core/tabs for reuse
- Fix getNextTabNameHelper to count tabs instead of finding max ID,
  preventing gaps in "Vorgang X" numbering
- Add canDeactivateTabCleanup guard to manage tab context based on cart state:
  * Preserves customer context if either cart (regular or reward) has items
  * Updates tab name with customer/organization name when context preserved
  * Resets tab to clean "Vorgang X" state when both carts empty
  * Handles navigation to global areas (without tab ID) gracefully
- Apply canDeactivateTabCleanup to checkout-summary and reward-order-confirmation routes
- Move tab cleanup logic from component ngOnDestroy to reusable guard
- Add comprehensive unit tests for getNextTabNameHelper

This ensures tabs maintain correct state after order completion, properly
display customer context when carts have items, and reset cleanly when
both carts are empty. The guard approach centralizes cleanup logic and
makes it reusable across checkout flows.

Ref: #5480
This commit is contained in:
Nino Righi
2025-11-13 14:10:43 +00:00
committed by Lorenz Hilpert
parent b89cf57a8d
commit 212203fb04
7 changed files with 336 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ import { CheckoutReviewComponent } from './checkout-review/checkout-review.compo
import { CheckoutSummaryComponent } from './checkout-summary/checkout-summary.component';
import { PageCheckoutComponent } from './page-checkout.component';
import { CheckoutReviewDetailsComponent } from './checkout-review/details/checkout-review-details.component';
import { canDeactivateTabCleanup } from '@isa/core/tabs';
const routes: Routes = [
{
@@ -22,10 +23,12 @@ const routes: Routes = [
{
path: 'summary',
component: CheckoutSummaryComponent,
canDeactivate: [canDeactivateTabCleanup],
},
{
path: 'summary/:orderIds',
component: CheckoutSummaryComponent,
canDeactivate: [canDeactivateTabCleanup],
},
{ path: '', pathMatch: 'full', redirectTo: 'review' },
],

View File

@@ -1,6 +1,7 @@
import { Routes } from '@angular/router';
import { CoreCommandModule } from '@core/command';
import { OMS_ACTION_HANDLERS } from '@isa/oms/data-access';
import { canDeactivateTabCleanup } from '@isa/core/tabs';
export const routes: Routes = [
{
@@ -12,5 +13,6 @@ export const routes: Routes = [
import('./reward-order-confirmation.component').then(
(m) => m.RewardOrderConfirmationComponent,
),
canDeactivate: [canDeactivateTabCleanup],
},
];

View File

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

View File

@@ -0,0 +1,71 @@
import { getNextTabNameHelper } from './helpers';
import { Tab } from './schemas';
import { EntityMap } from '@ngrx/signals/entities';
describe('getNextTabNameHelper', () => {
const createTab = (id: number, name: string): Tab => ({
id,
name,
createdAt: Date.now(),
activatedAt: Date.now(),
tags: [],
metadata: {},
location: { current: -1, locations: [] },
});
it('should return "Vorgang 1" when no tabs exist', () => {
const entities: EntityMap<Tab> = {};
const result = getNextTabNameHelper(entities);
expect(result).toBe('Vorgang 1');
});
it('should return "Vorgang 1" when no Vorgang tabs exist', () => {
const entities: EntityMap<Tab> = {
1: createTab(1, 'Bestellbestätigung'),
2: createTab(2, 'Artikelsuche'),
};
const result = getNextTabNameHelper(entities);
expect(result).toBe('Vorgang 1');
});
it('should return "Vorgang 2" when one Vorgang tab exists', () => {
const entities: EntityMap<Tab> = {
1: createTab(1, 'Vorgang 1'),
};
const result = getNextTabNameHelper(entities);
expect(result).toBe('Vorgang 2');
});
it('should return "Vorgang 4" when three Vorgang tabs exist', () => {
const entities: EntityMap<Tab> = {
1: createTab(1, 'Vorgang 1'),
2: createTab(2, 'Vorgang 3'),
3: createTab(3, 'Vorgang 2'),
};
const result = getNextTabNameHelper(entities);
expect(result).toBe('Vorgang 4');
});
it('should count only Vorgang tabs, ignore other tabs', () => {
const entities: EntityMap<Tab> = {
1: createTab(1, 'Vorgang 1'),
2: createTab(2, 'Bestellbestätigung'),
3: createTab(3, 'Vorgang 2'),
4: createTab(4, 'Artikelsuche'),
5: createTab(5, 'Max Mustermann - Bestellbestätigung'),
};
const result = getNextTabNameHelper(entities);
expect(result).toBe('Vorgang 3');
});
it('should handle gaps in numbering by counting tabs', () => {
const entities: EntityMap<Tab> = {
1: createTab(1, 'Vorgang 1'),
2: createTab(2, 'Vorgang 5'),
3: createTab(3, 'Vorgang 10'),
};
// Count is 3, so next should be Vorgang 4
const result = getNextTabNameHelper(entities);
expect(result).toBe('Vorgang 4');
});
});

View File

@@ -27,3 +27,75 @@ export function getMetadataHelper<T extends z.ZodTypeAny>(
}
return undefined;
}
/**
* Gets the next tab name for a Vorgang (process).
*
* @param entities - All tab entities
* @returns Tab name in format "Vorgang X" where X is the count of existing Vorgang tabs + 1
*
* Behavior:
* - Counts all tabs matching pattern "Vorgang \\d+"
* - Returns "Vorgang {count + 1}"
* - Example: If 2 Vorgang tabs exist -> returns "Vorgang 3"
*/
export function getNextTabNameHelper(entities: EntityMap<Tab>): string {
const REGEX_PROCESS_NAME = /^Vorgang \d+$/;
const allTabs = Object.values(entities);
// Count tabs with "Vorgang X" pattern
const vorgangTabCount = allTabs.filter((tab) =>
REGEX_PROCESS_NAME.test(tab.name),
).length;
return `Vorgang ${vorgangTabCount + 1}`;
}
// TODO: #5484 Move Logic to other location
/**
* Formats the customer name for tab display.
*
* For B2B accounts (have 'b2b' feature and not 'staff' feature), shows organization name.
* For regular customers, shows first and last name.
* Falls back to organization name if personal names are missing.
*
* @param customer - The customer data
* @returns Formatted customer name for display, or empty string if no name available
*/
export const formatCustomerTabNameHelper = (customer: {
firstName?: string | null;
lastName?: string | null;
organisation?: { name?: string | null } | null;
features?: Array<{ key?: string }> | null;
}): string => {
// Format tab name with customer info (same logic as details-main-view)
let name = `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim();
// Check if this is a B2B account (has 'b2b' feature and not 'staff' feature)
const isBusinessKonto =
!!customer.features?.some((f) => f.key === 'b2b') &&
!customer.features?.some((f) => f.key === 'staff');
// For B2B accounts or when names are missing, use organization name
if (
(isBusinessKonto && customer.organisation?.name) ||
(!customer.firstName && !customer.lastName)
) {
name = customer.organisation?.name ?? '';
}
return name;
};
// TODO: #5484 Move Logic to other location
/**
* Helper function to check if a shopping cart has items.
*
* @param cart - The shopping cart object or null/undefined
* @returns True if cart has items, false otherwise
*/
export const checkCartHasItemsHelper = (
cart: { items?: unknown[] | null } | null | undefined,
): boolean => {
return (cart?.items?.length ?? 0) > 0;
};

View File

@@ -0,0 +1,184 @@
import { inject } from '@angular/core';
import { CanDeactivateFn, Router } from '@angular/router';
import { TabService } from './tab';
import { logger } from '@isa/core/logging';
import {
CheckoutMetadataService,
ShoppingCartService,
} from '@isa/checkout/data-access';
import {
getNextTabNameHelper,
formatCustomerTabNameHelper,
checkCartHasItemsHelper,
} from './helpers';
import { DomainCheckoutService } from '@domain/checkout';
import { firstValueFrom } from 'rxjs';
// TODO: #5484 Move Guard to other location + Use resources for fetching cart data
/**
* CanDeactivate Guard that manages tab context based on shopping cart state.
*
* This guard checks both the regular shopping cart and reward shopping cart:
* - If BOTH carts are empty (or don't exist), the tab context is cleared and renamed to "Vorgang X"
* - If EITHER cart still has items:
* - Customer context is preserved
* - Tab name is updated to show customer name (or organization name for B2B)
* - process_type is set to 'cart-checkout' to show cart icon
*
* Usage: Apply to checkout-summary routes to automatically manage tab state after order completion.
*/
export const canDeactivateTabCleanup: CanDeactivateFn<unknown> = async () => {
const tabService = inject(TabService);
const checkoutMetadataService = inject(CheckoutMetadataService);
const shoppingCartService = inject(ShoppingCartService);
const domainCheckoutService = inject(DomainCheckoutService);
const router = inject(Router);
const log = logger(() => ({ guard: 'TabCleanup' }));
const tabId = tabService.activatedTabId();
if (!tabId) {
log.warn('No active tab found');
return true;
}
// Check if the target URL contains a tab ID
// Routes without tab ID (e.g., /filiale/package-inspection, /kunde/dashboard) are global areas
// Routes with tab ID (e.g., /537825823/return, /kunde/3296528359/search) are tab-specific
const nextUrl = router.getCurrentNavigation()?.finalUrl?.toString() ?? '';
const hasTabIdInUrl = /\/\d{10,}\//.test(nextUrl);
// If navigating to a route without tab ID (filial/dashboard areas), keep tab unchanged
if (!hasTabIdInUrl) {
log.debug(
'Navigating to global area (no tab ID), keeping tab unchanged',
() => ({
tabId,
nextUrl,
}),
);
return true;
}
try {
// Get shopping cart IDs from tab metadata
const shoppingCartId = checkoutMetadataService.getShoppingCartId(tabId);
const rewardShoppingCartId =
checkoutMetadataService.getRewardShoppingCartId(tabId);
// Load carts and check if they have items
let regularCart = null;
if (shoppingCartId) {
try {
regularCart = await shoppingCartService.getShoppingCart(shoppingCartId);
} catch (error) {
log.debug('Could not load regular shopping cart', () => ({
shoppingCartId,
error: (error as Error).message,
}));
}
}
let rewardCart = null;
if (rewardShoppingCartId) {
try {
rewardCart =
await shoppingCartService.getShoppingCart(rewardShoppingCartId);
} catch (error) {
log.debug('Could not load reward shopping cart', () => ({
rewardShoppingCartId,
error: (error as Error).message,
}));
}
}
const hasRegularItems = checkCartHasItemsHelper(regularCart);
const hasRewardItems = checkCartHasItemsHelper(rewardCart);
log.debug('Cart status check', () => ({
tabId,
shoppingCartId,
rewardShoppingCartId,
hasRegularItems,
hasRewardItems,
}));
// If either cart has items, preserve context and update tab name with customer info
if (hasRegularItems || hasRewardItems) {
log.info(
'Preserving checkout context - cart(s) still have items',
() => ({
tabId,
hasRegularItems,
hasRewardItems,
}),
);
try {
// Get customer from checkout service
const customer = await firstValueFrom(
domainCheckoutService.getCustomer({ processId: tabId }),
);
if (customer) {
const name = formatCustomerTabNameHelper(customer);
if (name) {
// Update tab name with customer info
tabService.patchTab(tabId, { name });
// Ensure process_type is 'cart' for proper cart icon display
tabService.patchTabMetadata(tabId, {
process_type: 'cart',
});
log.info('Updated tab name with customer info', () => ({
tabId,
customerName: name,
}));
}
}
} catch (error) {
// If customer data can't be loaded, just log and continue
log.warn('Could not load customer for tab name update', () => ({
tabId,
error: (error as Error).message,
}));
}
return true;
}
// Both carts are empty - clean up context
log.info('Cleaning up checkout context - both carts empty', () => ({
tabId,
}));
// Remove checkout state from store (customer, buyer, payer, etc.)
domainCheckoutService.removeProcess({ processId: tabId });
// Create new shopping cart for the cleaned tab
const newShoppingCart = await shoppingCartService.createShoppingCart();
checkoutMetadataService.setShoppingCartId(tabId, newShoppingCart.id);
// Clear tab metadata and location history, but keep process_type for cart icon
tabService.patchTabMetadata(tabId, { process_type: 'cart' });
tabService.clearLocationHistory(tabId);
// Rename tab to next "Vorgang X" based on count of existing Vorgang tabs
const tabName = getNextTabNameHelper(tabService.entityMap());
tabService.patchTab(tabId, { name: tabName });
log.info('Tab reset to clean state', () => ({
tabId,
name: tabName,
newShoppingCartId: newShoppingCart.id,
}));
return true;
} catch (error) {
log.error('Error in checkout cleanup guard', error as Error, () => ({
tabId,
}));
return true; // Allow navigation even if cleanup fails
}
};

View File

@@ -3,6 +3,7 @@ import { TabService } from './tab';
import { Tab } from './schemas';
import { inject } from '@angular/core';
import { logger } from '@isa/core/logging';
import { getNextTabNameHelper } from './helpers';
export const tabResolverFn: ResolveFn<Tab> = (route) => {
const log = logger(() => ({
@@ -22,9 +23,10 @@ export const tabResolverFn: ResolveFn<Tab> = (route) => {
let tab = tabService.entityMap()[tabId];
if (!tab) {
const tabName = getNextTabNameHelper(tabService.entityMap());
tab = tabService.addTab({
id: tabId,
name: 'Neuer Vorgang',
name: tabName,
metadata: {
process_type: 'cart',
},