Compare commits

...

32 Commits

Author SHA1 Message Date
Nino
f0acfb6af1 feature(ui-dialog): Adjusted Feedback Error Dialog displaying invalidErrors if available
Ref: #5417
2025-12-19 17:44:21 +01:00
Nino
d9b653073b fix(isa-app-customer): Fixed selection of other addresses in customer area
Ref: #5522
2025-12-18 17:51:44 +01:00
Nino Righi
de3edaa0f9 Merged PR 2077: fix(checkout-data-access, checkout-reward-shopping-cart, checkout-reward-sele...
fix(checkout-data-access, checkout-reward-shopping-cart, checkout-reward-selection-dialog): Show Low Stock message inside Dialog, Adjusted Item Identifyer so that mergedItems inside reward-selection-dialog service works properly, Adjusted Error Message Logic and Quantity Select Logic based on purchasing Options for Abholung

Ref: #5523
2025-12-10 17:12:47 +00:00
Nino Righi
964a6026a0 Merged PR 2076: fix(common-data-access, crm-data-access): Improved Error handling, handling i...
fix(common-data-access, crm-data-access): Improved Error handling, handling invalidProperties errors corretly inside crm customer card area

Refs: #5528, #5529
2025-12-10 17:11:22 +00:00
Nino Righi
83ad5f526e Merged PR 2075: fix(ui-layout, ui-input-controls, shared-filter): Set overlayPositions inside...
fix(ui-layout, ui-input-controls, shared-filter): Set overlayPositions inside filter-menu-button and outsourced the logic

Ref: #5526, #5477
2025-12-10 09:50:15 +00:00
Nino Righi
ccc5285602 Merged PR 2074: fix(remission): Implementation of Abort Remission Logic
fix(remission): Implementation of Abort Remission Logic

Ref: #5489
2025-12-10 09:48:49 +00:00
Nino Righi
7200eaefbf Merged PR 2073: fix(checkout-reward-shopping-cart): Preselect Purchasing Option Branch if Edi...
fix(checkout-reward-shopping-cart): Preselect Purchasing Option Branch if Edit item

Ref: #5516
2025-12-08 16:51:49 +00:00
Nino Righi
39e56a275e Merged PR 2072: fix(reward-order-confirmation): Only display one orderDate if other ones are...
fix(reward-order-confirmation): Only display one orderDate if other ones are equal

Ref: #5517
2025-12-08 15:20:19 +00:00
Nino Righi
6c41214d69 Merged PR 2071: fix(crm-customer-bon-redemption): Added Date Pipe to Bon
fix(crm-customer-bon-redemption): Added Date Pipe to Bon

Ref: #5521
2025-12-05 20:04:28 +00:00
Nino Righi
6e55b7b0da Merged PR 2070: fix(shared-filter, reward-catalog): Added Branch Filter Toggle to Reward HSC...
fix(shared-filter, reward-catalog): Added Branch Filter Toggle to Reward HSC View, Adjusted Controls Panel Filter Styling and Layout to fix mobile issues and added spacing to order-by-toolbar

Refs: #5514, #5475
2025-12-05 20:04:07 +00:00
Nino Righi
5711a75188 Merged PR 2068: fix(shared-filter): Adjusted Styling parameters for Height and Scrolling
fix(shared-filter): Adjusted Styling parameters for Height and Scrolling

Ref: #5476, #5477
2025-12-05 10:10:32 +00:00
Nino Righi
3696fb5b2d Merged PR 2069: feature(oms-data-access, oms-return-task-list): Return can now handle Rewards
feature(oms-data-access, oms-return-task-list): Return can now handle Rewards

#5373
2025-12-05 10:10:18 +00:00
Nino
7e7721b222 Merge branch 'release/4.5' into develop 2025-12-03 16:00:41 +01:00
Nino
14be1365bd fix(crm-loyalty-cards): Show always Points of first Card 2025-12-03 15:59:08 +01:00
Nino Righi
d5324675ef Merged PR 2067: fix(ui-layout): Ipad Dropdown Scrolling Fix
fix(ui-layout): Ipad Dropdown Scrolling Fix
2025-12-03 14:16:21 +00:00
Nino
f10338a48b Merge branch 'release/4.5' into develop 2025-12-02 17:33:50 +01:00
Nino
aa57d27924 fix(oms-return-details): Label Unknown Fix
Ref: #5513
2025-12-02 17:20:56 +01:00
Nino
6cb9aea7d1 Merge branch 'release/4.5' into develop 2025-12-02 17:18:11 +01:00
Lorenz Hilpert
fdfb54a3a0 Merged PR 2065: ♻️ refactor(core-navigation): remove library and use TabService directly
♻️ refactor(core-navigation): remove library and use TabService directly

Remove @isa/core/navigation library entirely as it was just a thin
wrapper around TabService.patchTabMetadata(). Consumers now use
TabService directly for scoped metadata operations.

Changes:
- Delete libs/core/navigation/ (~12 files, ~2900 LOC removed)
- Update 6 consumer components to use TabService directly
- Remove @isa/core/navigation path alias from tsconfig.base.json
- All operations now synchronous (removed async/await)

Migration pattern:
- preserveContext() → patchTabMetadata(tabId, { [scope]: data })
- restoreContext() → activatedTab()?.metadata?.[scope]
- restoreAndClearContext() → get + patchTabMetadata(tabId, { [scope]: null })

Refs #5502
2025-12-02 15:41:18 +00:00
Nino
5f94549539 fix(oms-return-details): Dropdown Label and Select Bullet Styling Adjustments
Ref: #5513
2025-12-02 16:32:57 +01:00
Nino Righi
aee63711e4 Merged PR 2066: fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale sc...
fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale scroll events from closing dropdown on open

Delay scroll listener registration using requestAnimationFrame when
activating CloseOnScrollDirective. This prevents stale scroll events
still in the event queue from immediately triggering closeOnScroll
when opening the dropdown after scrolling.

Also adds conditional rendering for product format and publication date
in return-details-order-group-item component.

Refs: #5513
2025-12-02 14:02:32 +00:00
Nino Righi
a3c865e39c Merged PR 2064: feature(oms-return-details): Display Loyalty Points if order is Reward Order
feature(oms-return-details): Display Loyalty Points if order is Reward Order

Ref: #5374
2025-12-02 12:40:44 +00:00
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
Nino Righi
0670dbfdb1 Merged PR 2063: fix(domain-checkout): After refreshing cart availabilities always keep previo...
fix(domain-checkout): After refreshing cart availabilities always keep previous selected price from purchasing options modal

Ref: #5488
2025-12-02 11:59:35 +00:00
Lorenz Hilpert
db4f30af86 🔧 chore: improve skills cross-references and CLAUDE.md guidance
- Add --amend option with safety rules to commit command
- Add logging skill cross-references to angular-template and html-template
- Fix logging skill name from logging-helper to logging
- Add extended thinking triggers, context monitoring, and code investigation
  rules to CLAUDE.md
2025-12-02 12:57:27 +01:00
Lorenz Hilpert
39b945ae88 📝 docs: add reference documentation for specialist skills
Add migration-patterns.md for test-migration-specialist (Jest to Vitest)
and zod-patterns.md for type-safety-engineer (Zod validation patterns).
2025-12-02 12:57:11 +01:00
Lorenz Hilpert
a2b29c0525 ♻️ refactor: convert architecture-documentation from command to skill
Move architecture documentation generation from a slash command to a
skill with comprehensive reference materials (C4 model, Arc42, ADR templates).
2025-12-02 12:56:56 +01:00
Lorenz Hilpert
2c385210db 🔧 chore: add frontmatter metadata to docs commands
Add allowed-tools, argument-hint, and description frontmatter to
docs-library.md and docs-refresh-reference.md for better discoverability.
2025-12-02 12:56:40 +01:00
Lorenz Hilpert
46999cc04c 🔧 chore: consolidate quality commands into single quality.md
Merge quality-bundle-analyze.md and quality-coverage.md into a unified
quality.md command with subcommand support (bundle, coverage).
2025-12-02 12:56:20 +01:00
Nino Righi
ee9f030a99 Merged PR 2062: fix(isa-app-customer): Clear Navigation State Context if Customer Area gets d...
fix(isa-app-customer): Clear Navigation State Context if Customer Area gets destroyed

Ref: #5512
2025-12-01 11:25:11 +00:00
Lorenz Hilpert
5aded6ff8e 🩹 fix(ui-input-controls): remove top padding from dropdown options when filter present 2025-11-28 18:18:57 +01:00
Nino Righi
7884e1af32 Merged PR 2059: feature(ui-modal): add QR code display for URLs in dialog modals
feature(ui-modal): add QR code display for URLs in dialog modals

Add automatic URL detection and QR code rendering in dialog modals:
- Parse dialog content to extract URLs (http/https)
- Display extracted URLs as QR codes using angularx-qrcode library
- Split content around URL to show text before and after the QR code
- Auto-detect URLs by default, with optional showUrlAsQrCode override
- Add comprehensive unit tests for URL parsing helpers

Ref: #5511
2025-11-27 16:41:51 +00:00
153 changed files with 6570 additions and 4674 deletions

View File

@@ -174,6 +174,42 @@ Example of splitting commits:
## Command Options
- `--no-verify`: Skip running the pre-commit checks (lint, build, generate:docs)
- `--amend`: Amend the previous commit (RESTRICTED - see rules below)
## Amend Rules (CRITICAL)
**ONLY use `--amend` in these specific cases:**
1. **Adding pre-commit hook fixes**: If a pre-commit hook modified files (formatting, linting auto-fixes), you may amend to include those changes.
2. **Before amending, ALWAYS verify:**
```bash
# Check authorship - NEVER amend another developer's commit
git log -1 --format='%an %ae'
# Check not pushed - NEVER amend pushed commits
git status # Should show "Your branch is ahead of..."
```
3. **If either check fails:**
- Create a NEW commit instead
- Never amend commits authored by others
- Never amend commits already pushed to remote
**Example workflow for pre-commit hook changes:**
```bash
# 1. Initial commit triggers pre-commit hook
git commit -m "feat(scope): add feature"
# Hook modifies files...
# 2. Verify safe to amend
git log -1 --format='%an %ae' # Your name/email
git status # "Your branch is ahead..."
# 3. Stage hook changes and amend
git add .
git commit --amend --no-edit
```
## Important Notes

View File

@@ -1,94 +0,0 @@
---
allowed-tools: Read, Write, Edit, Bash
argument-hint: [framework] | --c4-model | --arc42 | --adr | --plantuml | --full-suite
description: Generate comprehensive architecture documentation with diagrams, ADRs, and interactive visualization
---
# Architecture Documentation Generator
Generate comprehensive architecture documentation: $ARGUMENTS
## Current Architecture Context
- Project structure: !`find . -type f -name "*.json" -o -name "*.yaml" -o -name "*.toml" | head -5`
- Documentation exists: @docs/ or @README.md (if exists)
- Architecture files: !`find . -name "*architecture*" -o -name "*design*" -o -name "*.puml" | head -3`
- Services/containers: @docker-compose.yml or @k8s/ (if exists)
- API definitions: !`find . -name "*api*" -o -name "*openapi*" -o -name "*swagger*" | head -3`
## Task
Generate comprehensive architecture documentation with modern tooling and best practices:
1. **Architecture Analysis and Discovery**
- Analyze current system architecture and component relationships
- Identify key architectural patterns and design decisions
- Document system boundaries, interfaces, and dependencies
- Assess data flow and communication patterns
- Identify architectural debt and improvement opportunities
2. **Architecture Documentation Framework**
- Choose appropriate documentation framework and tools:
- **C4 Model**: Context, Containers, Components, Code diagrams
- **Arc42**: Comprehensive architecture documentation template
- **Architecture Decision Records (ADRs)**: Decision documentation
- **PlantUML/Mermaid**: Diagram-as-code documentation
- **Structurizr**: C4 model tooling and visualization
- **Draw.io/Lucidchart**: Visual diagramming tools
3. **System Context Documentation**
- Create high-level system context diagrams
- Document external systems and integrations
- Define system boundaries and responsibilities
- Document user personas and stakeholders
- Create system landscape and ecosystem overview
4. **Container and Service Architecture**
- Document container/service architecture and deployment view
- Create service dependency maps and communication patterns
- Document deployment architecture and infrastructure
- Define service boundaries and API contracts
- Document data persistence and storage architecture
5. **Component and Module Documentation**
- Create detailed component architecture diagrams
- Document internal module structure and relationships
- Define component responsibilities and interfaces
- Document design patterns and architectural styles
- Create code organization and package structure documentation
6. **Data Architecture Documentation**
- Document data models and database schemas
- Create data flow diagrams and processing pipelines
- Document data storage strategies and technologies
- Define data governance and lifecycle management
- Create data integration and synchronization documentation
7. **Security and Compliance Architecture**
- Document security architecture and threat model
- Create authentication and authorization flow diagrams
- Document compliance requirements and controls
- Define security boundaries and trust zones
- Create incident response and security monitoring documentation
8. **Quality Attributes and Cross-Cutting Concerns**
- Document performance characteristics and scalability patterns
- Create reliability and availability architecture documentation
- Document monitoring and observability architecture
- Define maintainability and evolution strategies
- Create disaster recovery and business continuity documentation
9. **Architecture Decision Records (ADRs)**
- Create comprehensive ADR template and process
- Document historical architectural decisions and rationale
- Create decision tracking and review process
- Document trade-offs and alternatives considered
- Set up ADR maintenance and evolution procedures
10. **Documentation Automation and Maintenance**
- Set up automated diagram generation from code annotations
- Configure documentation pipeline and publishing automation
- Set up documentation validation and consistency checking
- Create documentation review and approval process
- Train team on architecture documentation practices and tools
- Set up documentation versioning and change management

View File

@@ -1,3 +1,9 @@
---
allowed-tools: Read, Write, Edit, Bash, Grep, Glob, Task
argument-hint: [library-name]
description: Generate or update README.md for a specific library with comprehensive documentation
---
# /docs:library - Generate/Update Library README
Generate or update README.md for a specific library with comprehensive documentation.

View File

@@ -1,3 +1,9 @@
---
allowed-tools: Read, Write, Edit, Bash, Grep, Glob
argument-hint: --dry-run | --force
description: Regenerate library reference documentation (docs/library-reference.md) by scanning all monorepo libraries
---
# /docs:refresh-reference - Regenerate Library Reference
Regenerate the library reference documentation (`docs/library-reference.md`) by scanning all libraries in the monorepo.

View File

@@ -1,129 +0,0 @@
# /quality:bundle-analyze - Analyze Bundle Sizes
Analyze production bundle sizes and provide optimization recommendations. Project thresholds: 2MB warning, 5MB error.
## Tasks
### 1. Run Production Build
```bash
# Clean previous build
rm -rf dist/
# Build for production
npm run build-prod
```
### 2. Analyze Bundle Output
```bash
# List bundle files with sizes
ls -lh dist/apps/isa-app/browser/*.js | awk '{print $9, $5}'
# Get total bundle size
du -sh dist/apps/isa-app/browser/
```
### 3. Identify Large Files
Parse build output and identify:
- Main bundle size
- Lazy-loaded chunk sizes
- Vendor chunks
- Files exceeding thresholds:
- **Warning**: > 2MB
- **Error**: > 5MB
### 4. Analyze Dependencies
```bash
# Check for duplicate dependencies
npm ls --depth=0 | grep -E "UNMET|deduped"
# Show largest node_modules packages
du -sh node_modules/* | sort -rh | head -20
```
### 5. Source Map Analysis
Use source maps to identify large contributors:
```bash
# Install source-map-explorer if needed
npm install -g source-map-explorer
# Analyze main bundle
source-map-explorer dist/apps/isa-app/browser/main.*.js
```
### 6. Generate Recommendations
Based on analysis, provide actionable recommendations:
**If bundle > 2MB:**
- Identify heavy dependencies to replace or remove
- Suggest lazy loading opportunities
- Check for unused imports
**Code Splitting Opportunities:**
- Large feature modules that could be lazy-loaded
- Heavy libraries that could be dynamically imported
- Vendor code that could be split into separate chunks
**Dependency Optimization:**
- Replace large libraries with smaller alternatives
- Remove unused dependencies
- Use tree-shakeable imports
**Build Configuration:**
- Enable/optimize compression
- Check for source maps in production (should be disabled)
- Verify optimization flags
### 7. Comparative Analysis
If previous build data exists:
```bash
# Compare with previous build
# (Requires manual tracking or CI/CD integration)
echo "Current build: $(du -sh dist/apps/isa-app/browser/ | awk '{print $1}')"
```
### 8. Generate Report
Create formatted report with:
- Total bundle size with threshold status (✅ < 2MB, ⚠️ 2-5MB, ❌ > 5MB)
- Main bundle and largest chunks
- Top 10 largest dependencies
- Optimization recommendations prioritized by impact
- Lazy loading opportunities
## Output Format
```
Bundle Analysis Report
======================
Total Size: X.XX MB [STATUS]
Main Bundle: X.XX MB
Largest Chunks:
- chunk-name.js: X.XX MB
- ...
Largest Dependencies:
1. dependency-name: X.XX MB
...
Recommendations:
🔴 Critical (> 5MB):
- [Action items]
⚠️ Warning (2-5MB):
- [Action items]
✅ Optimization Opportunities:
- [Action items]
Lazy Loading Candidates:
- [Feature modules]
```
## Error Handling
- Build failures: Show error and suggest fixes
- Missing tools: Offer to install (source-map-explorer)
- No dist folder: Run build first
## References
- CLAUDE.md Build Configuration section
- Angular build optimization: https://angular.dev/tools/cli/build
- package.json (build-prod script)

View File

@@ -1,201 +0,0 @@
# /quality:coverage - Generate Test Coverage Report
Generate comprehensive test coverage report with recommendations for improving coverage.
## Parameters
- `library-name` (optional): Specific library to analyze. If omitted, analyzes all libraries.
## Tasks
### 1. Run Coverage Analysis
```bash
# Single library
npx nx test [library-name] --skip-nx-cache --coverage
# All libraries (if no library specified)
npm run ci # Runs all tests with coverage
```
### 2. Parse Coverage Report
Coverage output typically in:
- `coverage/libs/[domain]/[layer]/[name]/`
- Look for `coverage-summary.json` or text output
Extract metrics:
- **Line coverage**: % of executable lines tested
- **Branch coverage**: % of conditional branches tested
- **Function coverage**: % of functions called in tests
- **Statement coverage**: % of statements executed
### 3. Identify Uncovered Code
Parse coverage report to find:
- **Uncovered files**: Files with 0% coverage
- **Partially covered files**: < 80% coverage
- **Uncovered lines**: Specific line numbers not tested
- **Uncovered branches**: Conditional paths not tested
```bash
# List files with coverage below 80%
# (Parse from coverage JSON output)
```
### 4. Categorize Coverage Gaps
**Critical (High Risk):**
- Service methods handling business logic
- Data transformation functions
- Error handling code paths
- Security-related functions
- State management store actions
**Important (Medium Risk):**
- Component public methods
- Utility functions
- Validators
- Pipes and filters
- Guard functions
**Low Priority:**
- Getters/setters
- Simple property assignments
- Console logging
- Type definitions
### 5. Generate Recommendations
For each coverage gap, provide:
- **File and line numbers**
- **Risk level** (Critical/Important/Low)
- **Suggested test type** (unit/integration)
- **Test approach** (example test scenario)
Example:
```
📍 libs/oms/data-access/src/lib/services/order.service.ts:45-52
🔴 Critical - Business Logic
❌ 0% coverage - Error handling path
Recommended test:
it('should handle API error when fetching order', async () => {
// Mock API to return error
// Call method
// Verify error handling
});
```
### 6. Calculate Coverage Trends
If historical data available:
- Compare with previous coverage percentage
- Show improvement/regression
- Identify files with declining coverage
### 7. Generate HTML Report
```bash
# Open coverage report in browser (if available)
open coverage/libs/[domain]/[layer]/[name]/index.html
```
### 8. Create Coverage Summary Report
**Overall Metrics:**
```
Coverage Summary for [library-name]
====================================
Line Coverage: XX.X% (XXX/XXX lines)
Branch Coverage: XX.X% (XXX/XXX branches)
Function Coverage: XX.X% (XXX/XXX functions)
Statement Coverage: XX.X% (XXX/XXX statements)
Target: 80% (Recommended minimum)
Status: ✅ Met / ⚠️ Below Target / 🔴 Critical
```
**Files Needing Attention:**
```
🔴 Critical (< 50% coverage):
1. service-name.service.ts - 35% (business logic)
2. data-processor.ts - 42% (transformations)
⚠️ Below Target (50-79% coverage):
3. component-name.component.ts - 68%
4. validator.ts - 72%
✅ Well Covered (≥ 80% coverage):
- Other files maintaining good coverage
```
**Top Priority Tests to Add:**
1. [File:Line] - [Description] - [Risk Level]
2. ...
### 9. Framework-Specific Notes
**Vitest:**
- Coverage provider: v8 or istanbul
- Config in `vitest.config.ts`
- Coverage thresholds configurable
**Jest:**
- Coverage collected via `--coverage` flag
- Config in `jest.config.ts`
- Coverage directory: `coverage/`
### 10. Set Coverage Thresholds (Optional)
Suggest adding to test config:
```typescript
// vitest.config.ts
coverage: {
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
```
## Output Format
```
Test Coverage Report
====================
Library: [name]
Test Framework: [Vitest/Jest]
Generated: [timestamp]
📊 Coverage Metrics
-------------------
Lines: XX.X% ████████░░ (XXX/XXX)
Branches: XX.X% ███████░░░ (XXX/XXX)
Functions: XX.X% █████████░ (XXX/XXX)
Statements: XX.X% ████████░░ (XXX/XXX)
🎯 Target: 80% | Status: [✅/⚠️/🔴]
🔍 Coverage Gaps
----------------
[Categorized list with priorities]
💡 Recommendations
------------------
[Prioritized list of tests to add]
📈 Next Steps
-------------
1. Focus on critical coverage gaps first
2. Add tests for business logic in [files]
3. Consider setting coverage thresholds
4. Re-run: npx nx test [library-name] --skip-nx-cache --coverage
```
## Error Handling
- No coverage data: Ensure `--coverage` flag used
- Missing library: Verify library name is correct
- Coverage tool not configured: Check test config for coverage setup
## References
- docs/guidelines/testing.md
- CLAUDE.md Testing Framework section
- Vitest coverage: https://vitest.dev/guide/coverage
- Jest coverage: https://jestjs.io/docs/configuration#collectcoverage-boolean

188
.claude/commands/quality.md Normal file
View File

@@ -0,0 +1,188 @@
---
allowed-tools: Read, Write, Edit, Bash, Grep, Glob
argument-hint: bundle | coverage [library-name] | --all
description: Analyze code quality: bundle sizes, test coverage, with optimization recommendations
---
# /quality - Code Quality Analysis
Comprehensive quality analysis including bundle sizes and test coverage.
## Subcommands
- `bundle` - Analyze production bundle sizes
- `coverage [library-name]` - Test coverage analysis
- No argument - Run both analyses
- `[library-name]` - Coverage for specific library
## Bundle Analysis
### 1. Run Production Build
```bash
# Clean previous build
rm -rf dist/
# Build for production
npm run build-prod
```
### 2. Analyze Bundle Output
```bash
# List bundle files with sizes
ls -lh dist/apps/isa-app/browser/*.js | awk '{print $9, $5}'
# Get total bundle size
du -sh dist/apps/isa-app/browser/
```
### 3. Identify Large Files
Parse build output and identify:
- Main bundle size
- Lazy-loaded chunk sizes
- Vendor chunks
- Files exceeding thresholds:
- **Warning**: > 2MB
- **Error**: > 5MB
### 4. Analyze Dependencies
```bash
# Check for duplicate dependencies
npm ls --depth=0 | grep -E "UNMET|deduped"
# Show largest node_modules packages
du -sh node_modules/* | sort -rh | head -20
```
### 5. Source Map Analysis (Optional)
```bash
# Install source-map-explorer if needed
npm install -g source-map-explorer
# Analyze main bundle
source-map-explorer dist/apps/isa-app/browser/main.*.js
```
### 6. Bundle Recommendations
**If bundle > 2MB:**
- Identify heavy dependencies to replace or remove
- Suggest lazy loading opportunities
- Check for unused imports
**Code Splitting Opportunities:**
- Large feature modules that could be lazy-loaded
- Heavy libraries that could be dynamically imported
**Dependency Optimization:**
- Replace large libraries with smaller alternatives
- Remove unused dependencies
- Use tree-shakeable imports
---
## Coverage Analysis
### 1. Run Coverage Analysis
```bash
# Single library
npx nx test [library-name] --skip-nx-cache --coverage
# All libraries (if no library specified)
npm run ci # Runs all tests with coverage
```
### 2. Parse Coverage Report
Coverage output in: `coverage/libs/[domain]/[layer]/[name]/`
Extract metrics:
- **Line coverage**: % of executable lines tested
- **Branch coverage**: % of conditional branches tested
- **Function coverage**: % of functions called in tests
- **Statement coverage**: % of statements executed
### 3. Identify Uncovered Code
Parse coverage report to find:
- **Uncovered files**: Files with 0% coverage
- **Partially covered files**: < 80% coverage
- **Uncovered lines**: Specific line numbers not tested
- **Uncovered branches**: Conditional paths not tested
### 4. Categorize Coverage Gaps
**Critical (High Risk):**
- Service methods handling business logic
- Data transformation functions
- Error handling code paths
- Security-related functions
- State management store actions
**Important (Medium Risk):**
- Component public methods
- Utility functions
- Validators and guards
**Low Priority:**
- Getters/setters
- Simple property assignments
### 5. Generate Recommendations
For each coverage gap provide:
- **File and line numbers**
- **Risk level** (Critical/Important/Low)
- **Suggested test type** (unit/integration)
- **Test approach** (example test scenario)
---
## Output Formats
### Bundle Report
```
Bundle Analysis Report
======================
Total Size: X.XX MB [STATUS]
Main Bundle: X.XX MB
Largest Chunks:
- chunk-name.js: X.XX MB
Largest Dependencies:
1. dependency-name: X.XX MB
Recommendations:
- [Prioritized action items]
```
### Coverage Report
```
Coverage Summary for [library-name]
====================================
Line Coverage: XX.X% (XXX/XXX lines)
Branch Coverage: XX.X% (XXX/XXX branches)
Function Coverage: XX.X% (XXX/XXX functions)
Target: 80% (Recommended minimum)
Status: [Met/Below Target/Critical]
Files Needing Attention:
[Categorized list with priorities]
Top Priority Tests to Add:
[Prioritized recommendations]
```
## Error Handling
- **Build failures**: Show error and suggest fixes
- **Missing tools**: Offer to install (source-map-explorer)
- **No coverage data**: Ensure `--coverage` flag used
- **Missing library**: Verify library name is correct
## References
- CLAUDE.md Build Configuration section
- docs/guidelines/testing.md
- Angular build optimization: https://angular.dev/tools/cli/build
- Vitest coverage: https://vitest.dev/guide/coverage

View File

@@ -18,6 +18,7 @@ Guide for modern Angular 20+ template patterns: control flow, lazy loading, proj
**Related Skills:** These skills work together when writing Angular templates:
- **[html-template](../html-template/SKILL.md)** - E2E testing attributes (`data-what`, `data-which`) and ARIA accessibility
- **[tailwind](../tailwind/SKILL.md)** - ISA design system styling (colors, typography, spacing, layout)
- **[logging](../logging/SKILL.md)** - MANDATORY logging in all Angular files using `@isa/core/logging`
## Control Flow (Angular 17+)

View File

@@ -0,0 +1,171 @@
---
name: architecture-documentation
description: Generate architecture documentation (C4, Arc42, ADRs, PlantUML). Auto-invoke when user mentions "architecture docs", "C4 model", "ADR", "document architecture", "system design", or "create architecture diagram".
---
# Architecture Documentation Skill
Generate comprehensive architecture documentation using modern frameworks and best practices.
## When to Use
- Creating or updating architecture documentation
- Generating C4 model diagrams (Context, Container, Component, Code)
- Writing Architecture Decision Records (ADRs)
- Documenting system design and component relationships
- Creating PlantUML or Mermaid diagrams
## Available Frameworks
### C4 Model
Best for: Visualizing software architecture at different abstraction levels
Levels:
1. **Context** - System landscape and external actors
2. **Container** - High-level technology choices (apps, databases, etc.)
3. **Component** - Internal structure of containers
4. **Code** - Class/module level detail (optional)
See: `@references/c4-model.md` for patterns and examples
### Arc42 Template
Best for: Comprehensive architecture documentation
Sections:
1. Introduction and Goals
2. Constraints
3. Context and Scope
4. Solution Strategy
5. Building Block View
6. Runtime View
7. Deployment View
8. Cross-cutting Concepts
9. Architecture Decisions
10. Quality Requirements
11. Risks and Technical Debt
12. Glossary
See: `@references/arc42.md` for template structure
### Architecture Decision Records (ADRs)
Best for: Documenting individual architectural decisions
See: `@references/adr-template.md` for format and examples
## Workflow
### 1. Discovery Phase
```bash
# Find existing architecture files
find . -name "*architecture*" -o -name "*.puml" -o -name "*.mmd"
# Identify service boundaries
cat nx.json docker-compose.yml
# Check for existing ADRs
ls -la docs/adr/ docs/decisions/
```
### 2. Analysis Phase
- Analyze codebase structure (`libs/`, `apps/`)
- Identify dependencies from `tsconfig.base.json` paths
- Review service boundaries from `project.json` tags
- Map data flow from API definitions
### 3. Documentation Phase
Based on the request, create appropriate documentation:
**For C4 diagrams:**
```
docs/architecture/
├── c4-context.puml
├── c4-container.puml
└── c4-component-[name].puml
```
**For ADRs:**
```
docs/adr/
├── 0001-record-architecture-decisions.md
├── 0002-[decision-title].md
└── template.md
```
**For Arc42:**
```
docs/architecture/
└── arc42/
├── 01-introduction.md
├── 02-constraints.md
└── ...
```
## ISA-Frontend Specific Context
### Monorepo Structure
- **apps/**: Angular applications
- **libs/**: Shared libraries organized by domain
- `libs/[domain]/feature/` - Feature modules
- `libs/[domain]/data-access/` - State management
- `libs/[domain]/ui/` - Presentational components
- `libs/[domain]/util/` - Utilities
### Key Architectural Patterns
- **Nx Monorepo** with strict module boundaries
- **NgRx Signal Store** for state management
- **Standalone Components** (Angular 20+)
- **Domain-Driven Design** library organization
### Documentation Locations
- ADRs: `docs/adr/`
- Architecture diagrams: `docs/architecture/`
- API documentation: Generated from Swagger/OpenAPI
## Output Standards
### PlantUML Format
```plantuml
@startuml C4_Context
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml
Person(user, "User", "System user")
System(system, "ISA System", "Main application")
System_Ext(external, "External API", "Third-party service")
Rel(user, system, "Uses")
Rel(system, external, "Calls")
@enduml
```
### Mermaid Format
```mermaid
graph TD
A[User] --> B[ISA App]
B --> C[API Gateway]
C --> D[Backend Services]
```
### ADR Format
```markdown
# ADR-XXXX: [Title]
## Status
[Proposed | Accepted | Deprecated | Superseded]
## Context
[What is the issue?]
## Decision
[What was decided?]
## Consequences
[What are the results?]
```
## Best Practices
1. **Start with Context** - Always begin with C4 Level 1 (System Context)
2. **Use Consistent Notation** - Stick to one diagramming tool/format
3. **Keep ADRs Atomic** - One decision per ADR
4. **Version Control** - Commit documentation with code changes
5. **Review Regularly** - Architecture docs decay; schedule reviews

View File

@@ -0,0 +1,213 @@
# Architecture Decision Record (ADR) Template
## Overview
Architecture Decision Records document significant architectural decisions along with their context and consequences.
## ADR Format
### Standard Template
```markdown
# ADR-XXXX: [Short Title]
## Status
[Proposed | Accepted | Deprecated | Superseded by ADR-YYYY]
## Date
YYYY-MM-DD
## Context
What is the issue that we're seeing that is motivating this decision or change?
## Decision
What is the change that we're proposing and/or doing?
## Consequences
### Positive
- Benefit 1
- Benefit 2
### Negative
- Drawback 1
- Drawback 2
### Neutral
- Side effect 1
## Alternatives Considered
### Option 1: [Name]
- Pros: ...
- Cons: ...
- Why rejected: ...
### Option 2: [Name]
- Pros: ...
- Cons: ...
- Why rejected: ...
## References
- [Link to related documentation]
- [Link to discussion thread]
```
## Example ADRs
### ADR-0001: Use Nx Monorepo
```markdown
# ADR-0001: Use Nx Monorepo for Project Organization
## Status
Accepted
## Date
2024-01-15
## Context
The ISA Frontend consists of multiple applications and shared libraries. We need a way to:
- Share code between applications
- Maintain consistent tooling and dependencies
- Enable efficient CI/CD with affected-based testing
- Enforce architectural boundaries
## Decision
We will use Nx as our monorepo tool with the following structure:
- `apps/` - Deployable applications
- `libs/` - Shared libraries organized by domain and type
## Consequences
### Positive
- Single version of dependencies across all projects
- Affected-based testing reduces CI time
- Consistent tooling (ESLint, Prettier, TypeScript)
- Built-in dependency graph visualization
### Negative
- Learning curve for team members new to Nx
- More complex initial setup
- All code in single repository increases clone time
### Neutral
- Requires discipline in library boundaries
- Need to maintain `project.json` and tags
## Alternatives Considered
### Polyrepo
- Pros: Simpler individual repos, independent deployments
- Cons: Dependency management nightmare, code duplication
- Why rejected: Too much overhead for code sharing
```
### ADR-0002: Adopt NgRx Signal Store
```markdown
# ADR-0002: Adopt NgRx Signal Store for State Management
## Status
Accepted
## Date
2024-03-01
## Context
We need a state management solution that:
- Integrates well with Angular signals
- Provides predictable state updates
- Supports devtools for debugging
- Has good TypeScript support
## Decision
We will use NgRx Signal Store for new features and gradually migrate existing stores.
## Consequences
### Positive
- Native signal integration
- Simpler boilerplate than classic NgRx
- Better performance with fine-grained reactivity
- Excellent TypeScript inference
### Negative
- Migration effort for existing NgRx stores
- Different patterns from classic NgRx
### Neutral
- Team needs to learn new API
## Alternatives Considered
### Classic NgRx (Store + Effects)
- Pros: Mature, well-documented
- Cons: Verbose boilerplate, doesn't leverage signals
- Why rejected: Signal Store is the future direction
### Akita
- Pros: Less boilerplate
- Cons: Not Angular-native, less community support
- Why rejected: NgRx has better Angular integration
```
## ADR Naming Convention
```
docs/adr/
├── 0000-adr-template.md # Template file
├── 0001-use-nx-monorepo.md
├── 0002-adopt-ngrx-signal-store.md
├── 0003-standalone-components.md
└── README.md # Index of all ADRs
```
## ADR Index Template
```markdown
# Architecture Decision Records
This directory contains Architecture Decision Records (ADRs) for the ISA Frontend.
## Index
| ADR | Title | Status | Date |
|-----|-------|--------|------|
| [0001](0001-use-nx-monorepo.md) | Use Nx Monorepo | Accepted | 2024-01-15 |
| [0002](0002-adopt-ngrx-signal-store.md) | Adopt NgRx Signal Store | Accepted | 2024-03-01 |
| [0003](0003-standalone-components.md) | Migrate to Standalone Components | Accepted | 2024-04-01 |
## Process
1. Copy `0000-adr-template.md` to `XXXX-short-title.md`
2. Fill in the template
3. Submit PR for review
4. Update status once decided
5. Update this index
```
## Best Practices
1. **One decision per ADR** - Keep ADRs focused
2. **Number sequentially** - Never reuse numbers
3. **Record context** - Why was this needed?
4. **Document alternatives** - Show what was considered
5. **Keep concise** - 1-2 pages max
6. **Update status** - Mark deprecated decisions
7. **Link related ADRs** - Reference superseding decisions
8. **Review regularly** - Quarterly ADR review meetings
## When to Write an ADR
Write an ADR when:
- Choosing a framework or library
- Defining code organization patterns
- Setting up infrastructure
- Establishing conventions
- Making trade-offs that affect multiple teams
Don't write an ADR for:
- Small implementation details
- Obvious choices with no alternatives
- Temporary solutions

View File

@@ -0,0 +1,268 @@
# Arc42 Architecture Documentation Template
## Overview
Arc42 is a template for architecture documentation with 12 sections covering all aspects of software architecture.
## Template Structure
### 1. Introduction and Goals
```markdown
# 1. Introduction and Goals
## 1.1 Requirements Overview
- Core business requirements driving the architecture
- Key functional requirements
- Quality goals and priorities
## 1.2 Quality Goals
| Priority | Quality Goal | Description |
|----------|-------------|-------------|
| 1 | Performance | < 200ms response time |
| 2 | Usability | Intuitive for store employees |
| 3 | Reliability | 99.9% uptime during store hours |
## 1.3 Stakeholders
| Role | Expectations |
|------|-------------|
| Store Employee | Fast, reliable daily operations |
| IT Operations | Easy deployment and monitoring |
| Development Team | Maintainable, testable code |
```
### 2. Architecture Constraints
```markdown
# 2. Architecture Constraints
## 2.1 Technical Constraints
| Constraint | Background |
|------------|------------|
| Angular 20+ | Company standard frontend framework |
| TypeScript strict | Type safety requirement |
| Browser support | Chrome 90+, Edge 90+ |
## 2.2 Organizational Constraints
| Constraint | Background |
|------------|------------|
| Monorepo | Nx-based shared codebase |
| CI/CD | Azure DevOps pipelines |
| Code review | All changes require PR approval |
## 2.3 Conventions
- Conventional commits
- ESLint/Prettier formatting
- Component-driven development
```
### 3. System Scope and Context
```markdown
# 3. System Scope and Context
## 3.1 Business Context
[C4 Level 1 - System Context Diagram]
| Neighbor | Description |
|----------|-------------|
| Store Employee | Primary user performing daily operations |
| Backend APIs | Provides business logic and data |
| Printer Service | Label and receipt printing |
## 3.2 Technical Context
[Deployment/Network Diagram]
| Interface | Protocol | Description |
|-----------|----------|-------------|
| REST API | HTTPS/JSON | Backend communication |
| WebSocket | WSS | Real-time updates |
| OAuth2 | HTTPS | Authentication |
```
### 4. Solution Strategy
```markdown
# 4. Solution Strategy
## Key Architectural Decisions
| Decision | Rationale |
|----------|-----------|
| Angular SPA | Rich interactive UI, offline capability |
| NgRx Signal Store | Predictable state management |
| Nx Monorepo | Code sharing, consistent tooling |
| Standalone Components | Better tree-shaking, simpler imports |
## Quality Achievement Strategies
| Quality Goal | Approach |
|--------------|----------|
| Performance | Lazy loading, caching, code splitting |
| Maintainability | Domain-driven library structure |
| Testability | Component isolation, dependency injection |
```
### 5. Building Block View
```markdown
# 5. Building Block View
## Level 1: Application Overview
[C4 Container Diagram]
## Level 2: Domain Decomposition
| Domain | Purpose | Libraries |
|--------|---------|-----------|
| CRM | Customer management | crm-feature-*, crm-data-access-* |
| OMS | Order management | oms-feature-*, oms-data-access-* |
| Checkout | Transactions | checkout-feature-*, checkout-data-access-* |
## Level 3: Feature Details
[C4 Component Diagrams per domain]
```
### 6. Runtime View
```markdown
# 6. Runtime View
## Scenario 1: User Login
```mermaid
sequenceDiagram
User->>App: Open application
App->>Auth: Redirect to login
Auth->>App: Return token
App->>API: Fetch user profile
API->>App: User data
App->>User: Display dashboard
```
## Scenario 2: Order Processing
[Sequence diagram for order flow]
```
### 7. Deployment View
```markdown
# 7. Deployment View
## Infrastructure
```mermaid
graph TD
CDN[CDN] --> Browser[User Browser]
Browser --> LB[Load Balancer]
LB --> API[API Gateway]
API --> Services[Backend Services]
```
## Environments
| Environment | URL | Purpose |
|-------------|-----|---------|
| Development | dev.isa.local | Local development |
| Staging | staging.isa.com | Integration testing |
| Production | isa.com | Live system |
```
### 8. Cross-cutting Concepts
```markdown
# 8. Cross-cutting Concepts
## 8.1 Domain Model
[Domain entity relationships]
## 8.2 Security
- Authentication: OAuth2/OIDC
- Authorization: Role-based access control
- Data protection: HTTPS, encrypted storage
## 8.3 Error Handling
- Global error interceptor
- User-friendly error messages
- Error logging to backend
## 8.4 Logging
- @isa/core/logging library
- Structured log format
- Log levels: trace, debug, info, warn, error
```
### 9. Architecture Decisions
```markdown
# 9. Architecture Decisions
See [ADR folder](../adr/) for detailed decision records.
## Key Decisions
- ADR-0001: Use Nx Monorepo
- ADR-0002: Adopt NgRx Signal Store
- ADR-0003: Migrate to Standalone Components
```
### 10. Quality Requirements
```markdown
# 10. Quality Requirements
## Quality Tree
```
Quality
├── Performance
│ ├── Response Time < 200ms
│ └── Time to Interactive < 3s
├── Reliability
│ ├── Uptime 99.9%
│ └── Graceful degradation
└── Maintainability
├── Test coverage > 80%
└── Clear module boundaries
```
## Quality Scenarios
| Scenario | Measure | Target |
|----------|---------|--------|
| Page load | Time to interactive | < 3s |
| API call | Response time | < 200ms |
| Build | CI pipeline duration | < 10min |
```
### 11. Risks and Technical Debt
```markdown
# 11. Risks and Technical Debt
## Identified Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Backend unavailable | Medium | High | Offline mode |
| Performance degradation | Low | Medium | Monitoring |
## Technical Debt
| Item | Priority | Effort |
|------|----------|--------|
| Legacy Jest tests | Medium | High |
| Any types in codebase | High | Medium |
```
### 12. Glossary
```markdown
# 12. Glossary
| Term | Definition |
|------|------------|
| ADR | Architecture Decision Record |
| C4 | Context, Container, Component, Code model |
| ISA | In-Store Application |
| SPA | Single Page Application |
```
## Best Practices
1. **Start with sections 1-4** - Goals, constraints, context, strategy
2. **Add diagrams to section 5** - Building block views
3. **Document decisions in section 9** - Link to ADRs
4. **Keep updated** - Review quarterly
5. **Use templates** - Consistent formatting

View File

@@ -0,0 +1,163 @@
# C4 Model Reference
## Overview
The C4 model provides a way to visualize software architecture at four levels of abstraction:
1. **Context** - System landscape
2. **Container** - Applications and data stores
3. **Component** - Internal structure
4. **Code** - Class/module detail (optional)
## Level 1: System Context Diagram
Shows the system under design and its relationships with users and external systems.
### PlantUML Template
```plantuml
@startuml C4_Context
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml
LAYOUT_WITH_LEGEND()
title System Context Diagram - ISA Frontend
Person(user, "Store Employee", "Uses the ISA application")
Person(admin, "Administrator", "Manages system configuration")
System(isa, "ISA Frontend", "Angular application for in-store operations")
System_Ext(backend, "ISA Backend", "REST API services")
System_Ext(auth, "Auth Provider", "Authentication service")
System_Ext(printer, "Printer Service", "Receipt/label printing")
Rel(user, isa, "Uses", "Browser")
Rel(admin, isa, "Configures", "Browser")
Rel(isa, backend, "API calls", "HTTPS/JSON")
Rel(isa, auth, "Authenticates", "OAuth2")
Rel(isa, printer, "Prints", "WebSocket")
@enduml
```
### Key Elements
- **Person**: Human users of the system
- **System**: The system being documented (highlighted)
- **System_Ext**: External systems the system depends on
- **Rel**: Relationships between elements
## Level 2: Container Diagram
Shows the high-level technology choices and how containers communicate.
### PlantUML Template
```plantuml
@startuml C4_Container
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
LAYOUT_WITH_LEGEND()
title Container Diagram - ISA Frontend
Person(user, "Store Employee")
System_Boundary(isa, "ISA Frontend") {
Container(spa, "SPA", "Angular 20", "Single-page application")
Container(pwa, "Service Worker", "Workbox", "Offline capability")
ContainerDb(storage, "Local Storage", "IndexedDB", "Offline data cache")
}
System_Ext(api, "ISA API Gateway")
System_Ext(cdn, "CDN", "Static assets")
Rel(user, spa, "Uses", "Browser")
Rel(spa, pwa, "Registers")
Rel(pwa, storage, "Caches data")
Rel(spa, api, "API calls", "REST/JSON")
Rel(spa, cdn, "Loads assets", "HTTPS")
@enduml
```
### Container Types
- **Container**: Application or service
- **ContainerDb**: Database or data store
- **ContainerQueue**: Message queue
## Level 3: Component Diagram
Shows the internal structure of a container.
### PlantUML Template
```plantuml
@startuml C4_Component
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml
LAYOUT_WITH_LEGEND()
title Component Diagram - OMS Feature Module
Container_Boundary(oms, "OMS Feature") {
Component(list, "Order List", "Angular Component", "Displays orders")
Component(detail, "Order Detail", "Angular Component", "Order management")
Component(store, "Order Store", "NgRx Signal Store", "State management")
Component(api, "Order API Service", "Angular Service", "API communication")
}
ContainerDb_Ext(backend, "OMS Backend API")
Rel(list, store, "Reads state")
Rel(detail, store, "Reads/writes state")
Rel(store, api, "Fetches data")
Rel(api, backend, "HTTP requests")
@enduml
```
## ISA-Frontend Domain Components
### Suggested Component Structure
```
libs/[domain]/
├── feature/ → Component diagrams
│ └── [feature]/
├── data-access/ → Store/API components
│ └── [store]/
├── ui/ → Presentational components
│ └── [component]/
└── util/ → Utility components
└── [util]/
```
### Domain Boundaries
- **CRM**: Customer management, loyalty
- **OMS**: Order management, returns
- **Checkout**: Payment, transactions
- **Remission**: Product returns processing
- **Catalogue**: Product information
## Mermaid Alternative
```mermaid
C4Context
title System Context - ISA Frontend
Person(user, "Store Employee", "Daily operations")
System(isa, "ISA Frontend", "Angular SPA")
System_Ext(backend, "Backend Services")
System_Ext(auth, "Auth Service")
Rel(user, isa, "Uses")
Rel(isa, backend, "API calls")
Rel(isa, auth, "Authenticates")
```
## Best Practices
1. **One diagram per level** - Don't mix abstraction levels
2. **Consistent naming** - Use same names across diagrams
3. **Show key relationships** - Not every possible connection
4. **Include legends** - Explain notation
5. **Keep it simple** - 5-20 elements per diagram max

View File

@@ -19,6 +19,7 @@ Use this skill when:
**Works seamlessly with:**
- **[angular-template](../angular-template/SKILL.md)** - Angular template syntax, control flow, and modern patterns
- **[tailwind](../tailwind/SKILL.md)** - ISA design system styling for visual design
- **[logging](../logging/SKILL.md)** - MANDATORY logging in all Angular components using `@isa/core/logging`
## Overview

View File

@@ -1,5 +1,5 @@
---
name: logging-helper
name: logging
description: This skill should be used when working with Angular components, directives, services, pipes, guards, or TypeScript classes. Logging is MANDATORY in all Angular files. Implements @isa/core/logging with logger() factory pattern, appropriate log levels, lazy evaluation for performance, error handling, and avoids console.log and common mistakes.
---

View File

@@ -0,0 +1,346 @@
# Jest to Vitest Migration Patterns
## Overview
This reference provides syntax mappings and patterns for migrating tests from Jest (with Spectator) to Vitest (with Angular Testing Library).
## Configuration Migration
### Jest Config → Vitest Config
**Before (jest.config.ts):**
```typescript
export default {
displayName: 'my-lib',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../coverage/libs/my-lib',
transform: {
'^.+\\.(ts|mjs|js|html)$': 'jest-preset-angular',
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
],
};
```
**After (vitest.config.ts):**
```typescript
import { defineConfig } from 'vitest/config';
import angular from '@analogjs/vite-plugin-angular';
export default defineConfig({
plugins: [angular()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['src/test-setup.ts'],
include: ['**/*.spec.ts'],
reporters: ['default'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
});
```
### Test Setup Migration
**Before (test-setup.ts - Jest):**
```typescript
import 'jest-preset-angular/setup-jest';
```
**After (test-setup.ts - Vitest):**
```typescript
import '@analogjs/vitest-angular/setup-zone';
```
## Import Changes
### Test Function Imports
**Before (Jest):**
```typescript
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
```
**After (Vitest):**
```typescript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
```
### Mock Imports
**Before (Jest):**
```typescript
jest.fn()
jest.spyOn()
jest.mock()
jest.useFakeTimers()
```
**After (Vitest):**
```typescript
vi.fn()
vi.spyOn()
vi.mock()
vi.useFakeTimers()
```
## Mock Migration Patterns
### Function Mocks
**Before (Jest):**
```typescript
const mockFn = jest.fn();
const mockFnWithReturn = jest.fn().mockReturnValue('value');
const mockFnWithAsync = jest.fn().mockResolvedValue('async value');
```
**After (Vitest):**
```typescript
const mockFn = vi.fn();
const mockFnWithReturn = vi.fn().mockReturnValue('value');
const mockFnWithAsync = vi.fn().mockResolvedValue('async value');
```
### Spy Migration
**Before (Jest):**
```typescript
const spy = jest.spyOn(service, 'method');
spy.mockImplementation(() => 'mocked');
```
**After (Vitest):**
```typescript
const spy = vi.spyOn(service, 'method');
spy.mockImplementation(() => 'mocked');
```
### Module Mocks
**Before (Jest):**
```typescript
jest.mock('@isa/core/logging', () => ({
logger: jest.fn(() => ({
info: jest.fn(),
error: jest.fn(),
})),
}));
```
**After (Vitest):**
```typescript
vi.mock('@isa/core/logging', () => ({
logger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
})),
}));
```
## Spectator → Angular Testing Library
### Component Testing
**Before (Spectator):**
```typescript
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
describe('MyComponent', () => {
let spectator: Spectator<MyComponent>;
const createComponent = createComponentFactory({
component: MyComponent,
imports: [CommonModule],
providers: [
{ provide: MyService, useValue: mockService },
],
});
beforeEach(() => {
spectator = createComponent();
});
it('should render title', () => {
expect(spectator.query('.title')).toHaveText('Hello');
});
it('should handle click', () => {
spectator.click('.button');
expect(mockService.doSomething).toHaveBeenCalled();
});
});
```
**After (Angular Testing Library):**
```typescript
import { render, screen, fireEvent } from '@testing-library/angular';
describe('MyComponent', () => {
it('should render title', async () => {
await render(MyComponent, {
imports: [CommonModule],
providers: [
{ provide: MyService, useValue: mockService },
],
});
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('should handle click', async () => {
await render(MyComponent, {
providers: [{ provide: MyService, useValue: mockService }],
});
fireEvent.click(screen.getByRole('button'));
expect(mockService.doSomething).toHaveBeenCalled();
});
});
```
### Query Selectors
| Spectator | Angular Testing Library |
|-----------|------------------------|
| `spectator.query('.class')` | `screen.getByTestId()` or `screen.getByRole()` |
| `spectator.queryAll('.class')` | `screen.getAllByRole()` |
| `spectator.query('button')` | `screen.getByRole('button')` |
| `spectator.query('[data-testid]')` | `screen.getByTestId()` |
### Events
| Spectator | Angular Testing Library |
|-----------|------------------------|
| `spectator.click(element)` | `fireEvent.click(element)` or `await userEvent.click(element)` |
| `spectator.typeInElement(value, element)` | `await userEvent.type(element, value)` |
| `spectator.blur(element)` | `fireEvent.blur(element)` |
| `spectator.focus(element)` | `fireEvent.focus(element)` |
### Service Testing
**Before (Spectator):**
```typescript
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
describe('MyService', () => {
let spectator: SpectatorService<MyService>;
const createService = createServiceFactory({
service: MyService,
providers: [
{ provide: HttpClient, useValue: mockHttp },
],
});
beforeEach(() => {
spectator = createService();
});
it('should fetch data', () => {
spectator.service.getData().subscribe(data => {
expect(data).toEqual(expectedData);
});
});
});
```
**After (TestBed):**
```typescript
import { TestBed } from '@angular/core/testing';
describe('MyService', () => {
let service: MyService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
MyService,
{ provide: HttpClient, useValue: mockHttp },
],
});
service = TestBed.inject(MyService);
});
it('should fetch data', () => {
service.getData().subscribe(data => {
expect(data).toEqual(expectedData);
});
});
});
```
## Async Testing
### Observable Testing
**Before (Jest):**
```typescript
it('should emit values', (done) => {
service.data$.subscribe({
next: (value) => {
expect(value).toBe(expected);
done();
},
});
});
```
**After (Vitest):**
```typescript
import { firstValueFrom } from 'rxjs';
it('should emit values', async () => {
const value = await firstValueFrom(service.data$);
expect(value).toBe(expected);
});
```
### Timer Mocks
**Before (Jest):**
```typescript
jest.useFakeTimers();
service.startTimer();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
jest.useRealTimers();
```
**After (Vitest):**
```typescript
vi.useFakeTimers();
service.startTimer();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
vi.useRealTimers();
```
## Common Matchers
| Jest | Vitest |
|------|--------|
| `expect(x).toBe(y)` | Same |
| `expect(x).toEqual(y)` | Same |
| `expect(x).toHaveBeenCalled()` | Same |
| `expect(x).toHaveBeenCalledWith(y)` | Same |
| `expect(x).toMatchSnapshot()` | `expect(x).toMatchSnapshot()` |
| `expect(x).toHaveText('text')` | `expect(x).toHaveTextContent('text')` (with jest-dom) |
## Migration Checklist
1. [ ] Update `vitest.config.ts`
2. [ ] Update `test-setup.ts`
3. [ ] Replace `jest.fn()` with `vi.fn()`
4. [ ] Replace `jest.spyOn()` with `vi.spyOn()`
5. [ ] Replace `jest.mock()` with `vi.mock()`
6. [ ] Replace Spectator with Angular Testing Library
7. [ ] Update queries to use accessible selectors
8. [ ] Update async patterns
9. [ ] Run tests and fix any remaining issues
10. [ ] Remove Jest dependencies from `package.json`

View File

@@ -0,0 +1,293 @@
# Zod Patterns Reference
## Overview
Zod is a TypeScript-first schema validation library. Use it for runtime validation at system boundaries (API responses, form inputs, external data).
## Basic Schema Patterns
### Primitive Types
```typescript
import { z } from 'zod';
// Basic types
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
// With constraints
const emailSchema = z.string().email();
const positiveNumber = z.number().positive();
const nonEmptyString = z.string().min(1);
const optionalString = z.string().optional();
const nullableString = z.string().nullable();
```
### Object Schemas
```typescript
// Basic object
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
age: z.number().int().positive().optional(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
});
// Infer TypeScript type from schema
type User = z.infer<typeof userSchema>;
// Partial and Pick utilities
const partialUser = userSchema.partial(); // All fields optional
const requiredUser = userSchema.required(); // All fields required
const pickedUser = userSchema.pick({ email: true, name: true });
const omittedUser = userSchema.omit({ createdAt: true });
```
### Array Schemas
```typescript
// Basic array
const stringArray = z.array(z.string());
// With constraints
const nonEmptyArray = z.array(z.string()).nonempty();
const limitedArray = z.array(z.number()).min(1).max(10);
// Tuple (fixed length, different types)
const coordinate = z.tuple([z.number(), z.number()]);
```
## API Response Validation
### Pattern: Validate API Responses
```typescript
// Define response schema
const apiResponseSchema = z.object({
data: z.object({
items: z.array(userSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
}),
meta: z.object({
timestamp: z.string().datetime(),
requestId: z.string().uuid(),
}),
});
// In Angular service
@Injectable({ providedIn: 'root' })
export class UserService {
#http = inject(HttpClient);
#logger = logger({ component: 'UserService' });
getUsers(): Observable<User[]> {
return this.#http.get('/api/users').pipe(
map((response) => {
const result = apiResponseSchema.safeParse(response);
if (!result.success) {
this.#logger.error('Invalid API response', {
errors: result.error.errors
});
throw new Error('Invalid API response');
}
return result.data.data.items;
})
);
}
}
```
### Pattern: Coerce Types
```typescript
// API returns string IDs, coerce to number
const productSchema = z.object({
id: z.coerce.number(), // "123" -> 123
price: z.coerce.number(), // "99.99" -> 99.99
inStock: z.coerce.boolean(), // "true" -> true
createdAt: z.coerce.date(), // "2024-01-01" -> Date
});
```
## Form Validation
### Pattern: Form Schema
```typescript
// Define form schema with custom error messages
const loginFormSchema = z.object({
email: z.string()
.email({ message: 'Invalid email address' }),
password: z.string()
.min(8, { message: 'Password must be at least 8 characters' })
.regex(/[A-Z]/, { message: 'Must contain uppercase letter' })
.regex(/[0-9]/, { message: 'Must contain number' }),
rememberMe: z.boolean().default(false),
});
// Use with Angular forms
type LoginForm = z.infer<typeof loginFormSchema>;
```
### Pattern: Cross-field Validation
```typescript
const passwordFormSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ['confirmPassword'], // Error path
}
);
```
## Type Guards
### Pattern: Create Type Guard from Schema
```typescript
// Schema
const customerSchema = z.object({
type: z.literal('customer'),
customerId: z.string(),
loyaltyPoints: z.number(),
});
// Type guard function
function isCustomer(value: unknown): value is z.infer<typeof customerSchema> {
return customerSchema.safeParse(value).success;
}
// Usage
if (isCustomer(data)) {
console.log(data.loyaltyPoints); // Type-safe access
}
```
### Pattern: Discriminated Unions
```typescript
const customerSchema = z.object({
type: z.literal('customer'),
customerId: z.string(),
});
const guestSchema = z.object({
type: z.literal('guest'),
sessionId: z.string(),
});
const userSchema = z.discriminatedUnion('type', [
customerSchema,
guestSchema,
]);
type User = z.infer<typeof userSchema>;
// User = { type: 'customer'; customerId: string } | { type: 'guest'; sessionId: string }
```
## Replacing `any` Types
### Before (unsafe)
```typescript
function processOrder(order: any) {
// No type safety
console.log(order.items.length);
console.log(order.customer.name);
}
```
### After (with Zod)
```typescript
const orderSchema = z.object({
id: z.string().uuid(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().positive(),
price: z.number().nonnegative(),
})),
customer: z.object({
name: z.string(),
email: z.string().email(),
}),
status: z.enum(['pending', 'confirmed', 'shipped', 'delivered']),
});
type Order = z.infer<typeof orderSchema>;
function processOrder(input: unknown): Order {
const order = orderSchema.parse(input); // Throws if invalid
console.log(order.items.length); // Type-safe
console.log(order.customer.name); // Type-safe
return order;
}
```
## Error Handling
### Pattern: Structured Error Handling
```typescript
const result = schema.safeParse(data);
if (!result.success) {
// Access formatted errors
const formatted = result.error.format();
// Access flat error list
const flat = result.error.flatten();
// Custom error mapping
const errors = result.error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code,
}));
}
```
## Transform Patterns
### Pattern: Transform on Parse
```typescript
const userInputSchema = z.object({
email: z.string().email().transform(s => s.toLowerCase()),
name: z.string().transform(s => s.trim()),
tags: z.string().transform(s => s.split(',')),
});
// Input: { email: "USER@EXAMPLE.COM", name: " John ", tags: "a,b,c" }
// Output: { email: "user@example.com", name: "John", tags: ["a", "b", "c"] }
```
### Pattern: Default Values
```typescript
const configSchema = z.object({
theme: z.enum(['light', 'dark']).default('light'),
pageSize: z.number().default(20),
features: z.array(z.string()).default([]),
});
```
## Best Practices
1. **Define schemas at module boundaries** - API services, form handlers
2. **Use `safeParse` for error handling** - Don't let validation throw unexpectedly
3. **Infer types from schemas** - Single source of truth
4. **Add meaningful error messages** - Help debugging and user feedback
5. **Use transforms for normalization** - Clean data on parse
6. **Keep schemas close to usage** - Colocate with services/components

3
.gitignore vendored
View File

@@ -80,3 +80,6 @@ CLAUDE.md
*.pyc
.vite
reports/
# Local iPad dev setup (proxy)
/local-dev/

View File

@@ -112,6 +112,52 @@ angular-template → html-template → logging → tailwind
Use `/clear` between unrelated tasks to prevent context degradation.
### Long-Running Task Pattern
For complex tasks approaching context limits:
1. Dump progress to a `.md` file (e.g., `progress.md`)
2. Use `/clear` to reset context
3. Resume by reading the progress file
4. Continue from where you left off
### Context Monitoring
- Use `/context` to check current token usage
- Fresh session baseline: ~20k tokens
- Consider `/compact` when approaching 150k tokens
- Prefer `/clear` over `/compact` when starting new topics
## Extended Thinking
Use progressive thinking depth for complex analysis:
| Trigger | Thinking Budget | Use Case |
|---------|----------------|----------|
| `"think"` | ~4k tokens | Basic analysis, simple decisions |
| `"think hard"` | ~10k tokens | Moderate complexity, multi-factor decisions |
| `"think harder"` | ~16k tokens | Deep analysis, architectural decisions |
| `"ultrathink"` | ~32k tokens | Maximum depth, critical planning |
**Examples:**
- "Think about how to structure this component"
- "Think hard about the best approach for state management"
- "Ultrathink about the architecture for this feature"
## Code Investigation (MANDATORY)
**Never speculate about code you haven't read.**
If user references a specific file:
1. **READ the file first** using the Read tool
2. **THEN provide analysis** based on actual contents
3. If file doesn't exist, **say so explicitly**
**Anti-Hallucination Rules:**
- Never describe code you haven't opened
- Never assume file contents based on names
- Never guess API signatures without documentation
- Always verify imports and dependencies exist
## Implementation Decisions
| Task Type | Required Agent | Escalation Path |

View File

@@ -1,4 +1,4 @@
import { isDevMode, NgModule } from '@angular/core';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import {
CanActivateCartGuard,
@@ -7,23 +7,17 @@ import {
CanActivateCustomerOrdersGuard,
CanActivateCustomerOrdersWithProcessIdGuard,
CanActivateCustomerWithProcessIdGuard,
CanActivateGoodsInGuard,
CanActivateProductGuard,
CanActivateProductWithProcessIdGuard,
CanActivateTaskCalendarGuard,
IsAuthenticatedGuard,
} from './guards';
import { CanActivateAssortmentGuard } from './guards/can-activate-assortment.guard';
import { CanActivatePackageInspectionGuard } from './guards/can-activate-package-inspection.guard';
import { MainComponent } from './main.component';
import { PreviewComponent } from './preview';
import {
BranchSectionResolver,
CustomerSectionResolver,
ProcessIdResolver,
} from './resolvers';
import { TokenLoginComponent, TokenLoginModule } from './token-login';
import { ProcessIdGuard } from './guards/process-id.guard';
import {
ActivateProcessIdGuard,
ActivateProcessIdWithConfigKeyGuard,
@@ -74,8 +68,12 @@ const routes: Routes = [
loadChildren: () =>
import('@page/catalog').then((m) => m.PageCatalogModule),
canActivate: [CanActivateProductWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
// TODO: Check if order and :processId/order is still being used
// If not, remove these routes and the related guards and resolvers
{
path: 'order',
loadChildren: () =>
@@ -87,7 +85,9 @@ const routes: Routes = [
loadChildren: () =>
import('@page/customer-order').then((m) => m.CustomerOrderModule),
canActivate: [CanActivateCustomerOrdersWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'customer',
@@ -100,7 +100,9 @@ const routes: Routes = [
loadChildren: () =>
import('@page/customer').then((m) => m.CustomerModule),
canActivate: [CanActivateCustomerWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'cart',
@@ -113,10 +115,13 @@ const routes: Routes = [
loadChildren: () =>
import('@page/checkout').then((m) => m.PageCheckoutModule),
canActivate: [CanActivateCartWithProcessIdGuard],
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'pickup-shelf',
canActivate: [ProcessIdGuard],
canActivate: [ActivateProcessIdGuard],
// NOTE: This is a workaround for the canActivate guard not being called
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
@@ -126,6 +131,9 @@ const routes: Routes = [
canActivate: [ActivateProcessIdGuard],
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
resolve: {
processId: ProcessIdResolver,
},
},
{ path: '**', redirectTo: 'dashboard', pathMatch: 'full' },
],
@@ -141,7 +149,7 @@ const routes: Routes = [
import('@page/task-calendar').then(
(m) => m.PageTaskCalendarModule,
),
canActivate: [CanActivateTaskCalendarGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('taskCalendar')],
},
{
path: 'pickup-shelf',
@@ -154,27 +162,23 @@ const routes: Routes = [
path: 'goods/in',
loadChildren: () =>
import('@page/goods-in').then((m) => m.GoodsInModule),
canActivate: [CanActivateGoodsInGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('goodsIn')],
},
// {
// path: 'remission',
// loadChildren: () =>
// import('@page/remission').then((m) => m.PageRemissionModule),
// canActivate: [CanActivateRemissionGuard],
// },
{
path: 'package-inspection',
loadChildren: () =>
import('@page/package-inspection').then(
(m) => m.PackageInspectionModule,
),
canActivate: [CanActivatePackageInspectionGuard],
canActivate: [
ActivateProcessIdWithConfigKeyGuard('packageInspection'),
],
},
{
path: 'assortment',
loadChildren: () =>
import('@page/assortment').then((m) => m.AssortmentModule),
canActivate: [CanActivateAssortmentGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('assortment')],
},
{ path: '**', redirectTo: 'task-calendar', pathMatch: 'full' },
],
@@ -243,13 +247,6 @@ const routes: Routes = [
},
];
if (isDevMode()) {
routes.unshift({
path: 'preview',
component: PreviewComponent,
});
}
@NgModule({
imports: [
RouterModule.forRoot(routes, {

View File

@@ -1,4 +1,5 @@
import { version } from '../../../../package.json';
import { IsaTitleStrategy } from '@isa/common/title-management';
import {
HTTP_INTERCEPTORS,
provideHttpClient,
@@ -56,7 +57,6 @@ import {
ScanditScanAdapterModule,
} from '@adapter/scan';
import * as Commands from './commands';
import { PreviewComponent } from './preview';
import { NativeContainerService } from '@external/native-container';
import { ShellModule } from '@shared/shell';
import { MainComponent } from './main.component';
@@ -87,6 +87,7 @@ import {
import { Store } from '@ngrx/store';
import { OAuthService } from 'angular-oauth2-oidc';
import z from 'zod';
import { TitleStrategy } from '@angular/router';
import { TabNavigationService } from '@isa/core/tabs';
registerLocaleData(localeDe, localeDeExtra);
@@ -270,7 +271,6 @@ const USER_SUB_FACTORY = () => {
CoreCommandModule.forRoot(Object.values(Commands)),
CoreLoggerModule.forRoot(),
AppStoreModule,
PreviewComponent,
AuthModule.forRoot(),
CoreApplicationModule.forRoot(),
UiModalModule.forRoot(),
@@ -330,6 +330,7 @@ const USER_SUB_FACTORY = () => {
useValue: 'EUR',
},
provideUserSubFactory(USER_SUB_FACTORY),
{ provide: TitleStrategy, useClass: IsaTitleStrategy },
],
})
export class AppModule {}

View File

@@ -1,47 +1,82 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { take } from 'rxjs/operators';
export const ActivateProcessIdGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
) => {
const application = inject(ApplicationService);
const processIdStr = route.params.processId;
if (!processIdStr) {
return false;
}
const processId = Number(processIdStr);
// Check if Process already exists
const process = await application.getProcessById$(processId).pipe(take(1)).toPromise();
if (!process) {
application.createCustomerProcess(processId);
}
application.activateProcess(processId);
return true;
};
export const ActivateProcessIdWithConfigKeyGuard: (key: string) => CanActivateFn =
(key) => async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const application = inject(ApplicationService);
const config = inject(Config);
const processId = config.get(`process.ids.${key}`);
if (isNaN(processId)) {
return false;
}
application.activateProcess(processId);
return true;
};
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
RouterStateSnapshot,
} from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { take } from 'rxjs/operators';
import z from 'zod';
export const ActivateProcessIdGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
) => {
const application = inject(ApplicationService);
const processIdStr = route.params.processId;
if (!processIdStr) {
return false;
}
const processId = Number(processIdStr);
// Check if Process already exists
const process = await application
.getProcessById$(processId)
.pipe(take(1))
.toPromise();
if (!process) {
application.createProcess({
id: processId,
type: 'cart',
section: 'customer',
name: `Vorgang ${processId}`,
});
}
application.activateProcess(processId);
return true;
};
export const ActivateProcessIdWithConfigKeyGuard: (
key: string,
) => CanActivateFn =
(key) =>
async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const application = inject(ApplicationService);
const config = inject(Config);
const processId = config.get(`process.ids.${key}`, z.coerce.number());
if (typeof processId !== 'number' || isNaN(processId)) {
return false;
}
const processTitle = config.get(
`process.titles.${key}`,
z.string().default(key),
);
const process = await application
.getProcessById$(processId)
.pipe(take(1))
.toPromise();
if (!process) {
await application.createProcess({
id: processId,
type: key,
section: 'customer', // Not important anymore
name: processTitle,
});
}
application.activateProcess(processId);
return true;
};

View File

@@ -1,34 +1,34 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { first } from 'rxjs/operators';
import { z } from 'zod';
@Injectable({ providedIn: 'root' })
export class CanActivateGoodsInGuard {
constructor(
private readonly _applicationService: ApplicationService,
private readonly _config: Config,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const pid = this._config.get('process.ids.goodsIn', z.number());
const process = await this._applicationService
.getProcessById$(pid)
.pipe(first())
.toPromise();
if (!process) {
await this._applicationService.createProcess({
id: this._config.get('process.ids.goodsIn'),
type: 'goods-in',
section: 'branch',
name: '',
});
}
this._applicationService.activateProcess(
this._config.get('process.ids.goodsIn'),
);
return true;
}
}
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { first } from 'rxjs/operators';
import { z } from 'zod';
@Injectable({ providedIn: 'root' })
export class CanActivateGoodsInGuard {
constructor(
private readonly _applicationService: ApplicationService,
private readonly _config: Config,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const pid = this._config.get('process.ids.goodsIn', z.number());
const process = await this._applicationService
.getProcessById$(pid)
.pipe(first())
.toPromise();
if (!process) {
await this._applicationService.createProcess({
id: this._config.get('process.ids.goodsIn'),
type: 'goods-in',
section: 'branch',
name: 'Abholfach',
});
}
this._applicationService.activateProcess(
this._config.get('process.ids.goodsIn'),
);
return true;
}
}

View File

@@ -1,3 +0,0 @@
// start:ng42.barrel
export * from './preview.component';
// end:ng42.barrel

View File

@@ -1,3 +0,0 @@
:host {
@apply grid min-h-screen content-center justify-center;
}

View File

@@ -1,10 +0,0 @@
<h1>Platform: {{ platform | json }}</h1>
<br />
<h1>{{ appVersion }}</h1>
<br />
<h1>{{ userAgent }}</h1>
<br />
<h1>Navigator: {{ navigator | json }}</h1>
<br />
<br />
<h1>Device: {{ device }}</h1>

View File

@@ -1,56 +0,0 @@
import { Platform, PlatformModule } from '@angular/cdk/platform';
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { BehaviorSubject } from 'rxjs';
@Component({
selector: 'app-preview',
templateUrl: 'preview.component.html',
styleUrls: ['preview.component.css'],
imports: [CommonModule, PlatformModule],
})
export class PreviewComponent {
selectedBranch$ = new BehaviorSubject<BranchDTO>({});
get appVersion() {
return 'App Version: ' + (window.navigator as any).appVersion;
}
get userAgent() {
return 'User Agent: ' + (window.navigator as any).userAgent;
}
get navigator() {
const nav = {};
for (const i in window.navigator) nav[i] = navigator[i];
return nav;
}
get platform() {
return this._platform;
}
get device() {
const isIpadNative = this._platform.IOS && !this._platform.SAFARI;
const isIpadMini6Native = window?.navigator?.userAgent?.includes('Macintosh') && !this._platform.SAFARI;
const isNative = isIpadNative || isIpadMini6Native;
const isPWA = this._platform.IOS && this._platform.SAFARI;
const isDesktop = !isNative && !isPWA;
if (isNative) {
if (isIpadMini6Native) {
return 'IPAD mini 6 Native App';
} else if (isIpadNative) {
return 'IPAD mini 2 Native App or IPAD mini 5 Native App';
}
} else if (isPWA) {
return 'IPAD Safari PWA';
} else if (isDesktop) return 'Desktop or Macintosh';
}
constructor(private readonly _platform: Platform) {}
setNewBranch(branch: BranchDTO) {
this.selectedBranch$.next(branch);
}
}

View File

@@ -1,4 +1,2 @@
// start:ng42.barrel
export * from './process-id.resolver';
export * from './section.resolver';
// end:ng42.barrel
export * from './process-id.resolver';
export * from './section.resolver';

View File

@@ -1,87 +1,86 @@
{
"title": "ISA - Local",
"silentRefresh": {
"interval": 300000
},
"debug": true,
"dev-scanner": true,
"@cdn/product-image": {
"url": "https://produktbilder.paragon-data.net"
},
"@core/auth": {
"issuer": "https://sso-test.paragon-data.de",
"clientId": "isa-client",
"responseType": "code",
"scope": "openid profile cmf_user isa-isa-webapi isa-checkout-webapi isa-cat-webapi isa-ava-webapi isa-crm-webapi isa-review-webapi isa-kpi-webapi isa-oms-webapi isa-nbo-webapi isa-print-webapi eis-service isa-inv-webapi isa-wws-webapi",
"showDebugInformation": true
},
"@core/logger": {
"logLevel": "debug"
},
"@swagger/isa": {
"rootUrl": "https://isa-test.paragon-data.net/isa/v1"
},
"@swagger/cat": {
"rootUrl": "https://isa-test.paragon-data.net/catsearch/v6"
},
"@swagger/av": {
"rootUrl": "https://isa-test.paragon-data.net/ava/v6"
},
"@swagger/checkout": {
"rootUrl": "https://isa-test.paragon-data.net/checkout/v6"
},
"@swagger/crm": {
"rootUrl": "https://isa-test.paragon-data.net/crm/v6"
},
"@swagger/oms": {
"rootUrl": "https://isa-test.paragon-data.net/oms/v6"
},
"@swagger/print": {
"rootUrl": "https://isa-test.paragon-data.net/print/v1"
},
"@swagger/eis": {
"rootUrl": "https://filialinformationsystem-test.paragon-systems.de/eiswebapi/v1"
},
"@swagger/remi": {
"rootUrl": "https://isa-test.paragon-data.net/inv/v6"
},
"@swagger/wws": {
"rootUrl": "https://isa-test.paragon-data.net/wws/v1"
},
"@domain/checkout": {
"olaExpiration": "5m"
},
"hubs": {
"notifications": {
"url": "https://isa-test.paragon-data.net/isa/v1/rt",
"enableAutomaticReconnect": false,
"httpOptions": {
"transport": 1,
"logMessageContent": true,
"skipNegotiation": true
}
}
},
"process": {
"ids": {
"goodsOut": 1000,
"goodsIn": 2000,
"taskCalendar": 3000,
"remission": 4000,
"packageInspection": 5000,
"assortment": 6000,
"pickupShelf": 7000
}
},
"checkForUpdates": 3600000,
"licence": {
"scandit": "Ae8F2Wx2RMq5Lvn7UUAlWzVFZTt2+ubMAF8XtDpmPlNkBeG/LWs1M7AbgDW0LQqYLnszClEENaEHS56/6Ts2vrJ1Ux03CXUjK3jUvZpF5OchXR1CpnmpepJ6WxPCd7LMVHUGG1BbwPLDTFjP3y8uT0caTSmmGrYQWAs4CZcEF+ZBabP0z7vfm+hCZF/ebj9qqCJZcW8nH/n19hohshllzYBjFXjh87P2lIh1s6yZS3OaQWWXo/o0AKdxx7T6CVyR0/G5zq6uYJWf6rs3euUBEhpzOZHbHZK86Lvy2AVBEyVkkcttlDW1J2fA4l1W1JV/Xibz8AQV6kG482EpGF42KEoK48paZgX3e1AQsqUtmqzw294dcP4zMVstnw5/WrwKKi/5E/nOOJT2txYP1ZufIjPrwNFsqTlv7xCQlHjMzFGYwT816yD5qLRLbwOtjrkUPXNZLZ06T4upvWwJDmm8XgdeoDqMjHdcO4lwji1bl9EiIYJ/2qnsk9yZ2FqSaHzn4cbiL0f5u2HFlNAP0GUujGRlthGhHi6o4dFU+WAxKsFMKVt+SfoQUazNKHFVQgiAklTIZxIc/HUVzRvOLMxf+wFDerraBtcqGJg+g/5mrWYqeDBGhCBHtKiYf6244IJ4afzNTiH1/30SJcRzXwbEa3A7q1fJTx9/nLTOfVPrJKBQs7f/OQs2dA7LDCel8mzXdbjvsNQaeU5+iCIAq6zbTNKy1xT8wwj+VZrQmtNJs+qeznD+u29nCM24h8xCmRpvNPo4/Mww/lrTNrrNwLBSn1pMIwsH7yS9hH0v0oNAM3A6bVtk1D9qEkbyw+xZa+MZGpMP0D0CdcsqHalPcm5r/Ik="
},
"gender": {
"0": "Keine Anrede",
"1": "Enby",
"2": "Herr",
"4": "Frau"
},
"@shared/icon": "/assets/icons.json"
}
{
"title": "ISA - Local",
"silentRefresh": {
"interval": 300000
},
"debug": true,
"dev-scanner": true,
"@cdn/product-image": {
"url": "https://produktbilder.paragon-data.net"
},
"@core/auth": {
"issuer": "https://sso-test.paragon-data.de",
"clientId": "isa-client",
"responseType": "code",
"scope": "openid profile cmf_user isa-isa-webapi isa-checkout-webapi isa-cat-webapi isa-ava-webapi isa-crm-webapi isa-review-webapi isa-kpi-webapi isa-oms-webapi isa-nbo-webapi isa-print-webapi eis-service isa-inv-webapi isa-wws-webapi",
"showDebugInformation": true
},
"@core/logger": {
"logLevel": "debug"
},
"@swagger/isa": {
"rootUrl": "https://isa-test.paragon-data.net/isa/v1"
},
"@swagger/cat": {
"rootUrl": "https://isa-test.paragon-data.net/catsearch/v6"
},
"@swagger/av": {
"rootUrl": "https://isa-test.paragon-data.net/ava/v6"
},
"@swagger/checkout": {
"rootUrl": "https://isa-test.paragon-data.net/checkout/v6"
},
"@swagger/crm": {
"rootUrl": "https://isa-test.paragon-data.net/crm/v6"
},
"@swagger/oms": {
"rootUrl": "https://isa-test.paragon-data.net/oms/v6"
},
"@swagger/print": {
"rootUrl": "https://isa-test.paragon-data.net/print/v1"
},
"@swagger/eis": {
"rootUrl": "https://filialinformationsystem-test.paragon-systems.de/eiswebapi/v1"
},
"@swagger/remi": {
"rootUrl": "https://isa-test.paragon-data.net/inv/v6"
},
"@swagger/wws": {
"rootUrl": "https://isa-test.paragon-data.net/wws/v1"
},
"@domain/checkout": {
"olaExpiration": "5m"
},
"hubs": {
"notifications": {
"url": "https://isa-test.paragon-data.net/isa/v1/rt",
"enableAutomaticReconnect": false,
"httpOptions": {
"transport": 1,
"logMessageContent": true,
"skipNegotiation": true
}
}
},
"process": {
"ids": {
"goodsOut": 1000,
"goodsIn": 2000,
"taskCalendar": 3000,
"packageInspection": 5000,
"assortment": 6000,
"pickupShelf": 2000
}
},
"checkForUpdates": 3600000,
"licence": {
"scandit": "Ae8F2Wx2RMq5Lvn7UUAlWzVFZTt2+ubMAF8XtDpmPlNkBeG/LWs1M7AbgDW0LQqYLnszClEENaEHS56/6Ts2vrJ1Ux03CXUjK3jUvZpF5OchXR1CpnmpepJ6WxPCd7LMVHUGG1BbwPLDTFjP3y8uT0caTSmmGrYQWAs4CZcEF+ZBabP0z7vfm+hCZF/ebj9qqCJZcW8nH/n19hohshllzYBjFXjh87P2lIh1s6yZS3OaQWWXo/o0AKdxx7T6CVyR0/G5zq6uYJWf6rs3euUBEhpzOZHbHZK86Lvy2AVBEyVkkcttlDW1J2fA4l1W1JV/Xibz8AQV6kG482EpGF42KEoK48paZgX3e1AQsqUtmqzw294dcP4zMVstnw5/WrwKKi/5E/nOOJT2txYP1ZufIjPrwNFsqTlv7xCQlHjMzFGYwT816yD5qLRLbwOtjrkUPXNZLZ06T4upvWwJDmm8XgdeoDqMjHdcO4lwji1bl9EiIYJ/2qnsk9yZ2FqSaHzn4cbiL0f5u2HFlNAP0GUujGRlthGhHi6o4dFU+WAxKsFMKVt+SfoQUazNKHFVQgiAklTIZxIc/HUVzRvOLMxf+wFDerraBtcqGJg+g/5mrWYqeDBGhCBHtKiYf6244IJ4afzNTiH1/30SJcRzXwbEa3A7q1fJTx9/nLTOfVPrJKBQs7f/OQs2dA7LDCel8mzXdbjvsNQaeU5+iCIAq6zbTNKy1xT8wwj+VZrQmtNJs+qeznD+u29nCM24h8xCmRpvNPo4/Mww/lrTNrrNwLBSn1pMIwsH7yS9hH0v0oNAM3A6bVtk1D9qEkbyw+xZa+MZGpMP0D0CdcsqHalPcm5r/Ik="
},
"gender": {
"0": "Keine Anrede",
"1": "Enby",
"2": "Herr",
"4": "Frau"
},
"@shared/icon": "/assets/icons.json"
}

View File

@@ -1,174 +1,196 @@
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { isBoolean, isNumber } from '@utils/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { ApplicationProcess } from './defs';
import {
removeProcess,
selectSection,
selectProcesses,
setSection,
addProcess,
setActivatedProcess,
selectActivatedProcess,
patchProcess,
patchProcessData,
selectTitle,
setTitle,
} from './store';
@Injectable()
export class ApplicationService {
private activatedProcessIdSubject = new BehaviorSubject<number>(undefined);
get activatedProcessId() {
return this.activatedProcessIdSubject.value;
}
get activatedProcessId$() {
return this.activatedProcessIdSubject.asObservable();
}
constructor(private store: Store) {}
getProcesses$(section?: 'customer' | 'branch') {
const processes$ = this.store.select(selectProcesses);
return processes$.pipe(
map((processes) => processes.filter((process) => (section ? process.section === section : true))),
);
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.getProcesses$().pipe(map((processes) => processes.find((process) => process.id === processId)));
}
getSection$() {
return this.store.select(selectSection);
}
getTitle$() {
return this.getSection$().pipe(
map((section) => {
return section === 'customer' ? 'Kundenbereich' : 'Filialbereich';
}),
);
}
/** @deprecated */
getActivatedProcessId$() {
return this.store.select(selectActivatedProcess).pipe(map((process) => process?.id));
}
activateProcess(activatedProcessId: number) {
this.store.dispatch(setActivatedProcess({ activatedProcessId }));
this.activatedProcessIdSubject.next(activatedProcessId);
}
removeProcess(processId: number) {
this.store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>) {
this.store.dispatch(patchProcess({ processId, changes }));
}
patchProcessData(processId: number, data: Record<string, any>) {
this.store.dispatch(patchProcessData({ processId, data }));
}
getSelectedBranch$(processId?: number): Observable<BranchDTO> {
if (!processId) {
return this.activatedProcessId$.pipe(
switchMap((processId) => this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch))),
);
}
return this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch));
}
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await this.getProcesses$('customer').pipe(first()).toPromise();
const processIds = processes.filter((x) => this.REGEX_PROCESS_NAME.test(x.name)).map((x) => +x.name.split(' ')[1]);
const maxId = processIds.length > 0 ? Math.max(...processIds) : 0;
const process: ApplicationProcess = {
id: processId ?? Date.now(),
type: 'cart',
name: `Vorgang ${maxId + 1}`,
section: 'customer',
closeable: true,
};
await this.createProcess(process);
return process;
}
async createProcess(process: ApplicationProcess) {
const existingProcess = await this.getProcessById$(process?.id).pipe(first()).toPromise();
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
if (!isNumber(process.id)) {
throw new Error('Process Id nicht gesetzt');
}
if (!isBoolean(process.closeable)) {
process.closeable = true;
}
if (!isBoolean(process.confirmClosing)) {
process.confirmClosing = true;
}
process.created = this._createTimestamp();
process.activated = 0;
this.store.dispatch(addProcess({ process }));
}
setSection(section: 'customer' | 'branch') {
this.store.dispatch(setSection({ section }));
}
getLastActivatedProcessWithSectionAndType$(
section: 'customer' | 'branch',
type: string,
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes
?.filter((process) => process.type === type)
?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
getLastActivatedProcessWithSection$(section: 'customer' | 'branch'): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
private _createTimestamp() {
return Date.now();
}
}
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { isBoolean, isNumber } from '@utils/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { ApplicationProcess } from './defs';
import {
removeProcess,
selectSection,
selectProcesses,
setSection,
addProcess,
setActivatedProcess,
selectActivatedProcess,
patchProcess,
patchProcessData,
selectTitle,
setTitle,
} from './store';
@Injectable()
export class ApplicationService {
private activatedProcessIdSubject = new BehaviorSubject<number>(undefined);
get activatedProcessId() {
return this.activatedProcessIdSubject.value;
}
get activatedProcessId$() {
return this.activatedProcessIdSubject.asObservable();
}
constructor(private store: Store) {}
getProcesses$(section?: 'customer' | 'branch') {
const processes$ = this.store.select(selectProcesses);
return processes$.pipe(
map((processes) =>
processes.filter((process) =>
section ? process.section === section : true,
),
),
);
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.getProcesses$().pipe(
map((processes) => processes.find((process) => process.id === processId)),
);
}
getSection$() {
return this.store.select(selectSection);
}
getTitle$() {
return this.getSection$().pipe(
map((section) => {
return section === 'customer' ? 'Kundenbereich' : 'Filialbereich';
}),
);
}
/** @deprecated */
getActivatedProcessId$() {
return this.store
.select(selectActivatedProcess)
.pipe(map((process) => process?.id));
}
activateProcess(activatedProcessId: number) {
this.store.dispatch(setActivatedProcess({ activatedProcessId }));
this.activatedProcessIdSubject.next(activatedProcessId);
}
removeProcess(processId: number) {
this.store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>) {
this.store.dispatch(patchProcess({ processId, changes }));
}
patchProcessData(processId: number, data: Record<string, any>) {
this.store.dispatch(patchProcessData({ processId, data }));
}
getSelectedBranch$(processId?: number): Observable<BranchDTO> {
if (!processId) {
return this.activatedProcessId$.pipe(
switchMap((processId) =>
this.getProcessById$(processId).pipe(
map((process) => process?.data?.selectedBranch),
),
),
);
}
return this.getProcessById$(processId).pipe(
map((process) => process?.data?.selectedBranch),
);
}
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await this.getProcesses$('customer')
.pipe(first())
.toPromise();
const processIds = processes
.filter((x) => this.REGEX_PROCESS_NAME.test(x.name))
.map((x) => +x.name.split(' ')[1]);
const maxId = processIds.length > 0 ? Math.max(...processIds) : 0;
const process: ApplicationProcess = {
id: processId ?? Date.now(),
type: 'cart',
name: `Vorgang ${maxId + 1}`,
section: 'customer',
closeable: true,
};
await this.createProcess(process);
return process;
}
async createProcess(process: ApplicationProcess) {
const existingProcess = await this.getProcessById$(process?.id)
.pipe(first())
.toPromise();
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
if (!isNumber(process.id)) {
throw new Error('Process Id nicht gesetzt');
}
if (!isBoolean(process.closeable)) {
process.closeable = true;
}
if (!isBoolean(process.confirmClosing)) {
process.confirmClosing = true;
}
process.created = this._createTimestamp();
process.activated = 0;
this.store.dispatch(addProcess({ process }));
}
setSection(section: 'customer' | 'branch') {
this.store.dispatch(setSection({ section }));
}
getLastActivatedProcessWithSectionAndType$(
section: 'customer' | 'branch',
type: string,
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes
?.filter((process) => process.type === type)
?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
getLastActivatedProcessWithSection$(
section: 'customer' | 'branch',
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
private _createTimestamp() {
return Date.now();
}
}

View File

@@ -1251,7 +1251,12 @@ export class DomainCheckoutService {
await this.updateItemInShoppingCart({
processId,
update: { availability },
update: {
availability: {
...availability,
price: item?.availability?.price ?? availability?.price,
}, // #5488 After Refreshing Availabilities in Cart make sure to keep the original selected price from purchasing options modal
},
shoppingCartItemId: item.id,
}).toPromise();

View File

@@ -35,7 +35,7 @@ const _domainCheckoutReducer = createReducer(
const now = Date.now();
for (let shoppingCartItem of addedShoppingCartItems) {
for (const shoppingCartItem of addedShoppingCartItems) {
if (shoppingCartItem.features?.orderType) {
entity.itemAvailabilityTimestamp[
`${shoppingCartItem.id}_${shoppingCartItem.features.orderType}`
@@ -275,7 +275,7 @@ function getOrCreateCheckoutEntity({
entities: Dictionary<CheckoutEntity>;
processId: number;
}): CheckoutEntity {
let entity = entities[processId];
const entity = entities[processId];
if (isNullOrUndefined(entity)) {
return {

View File

@@ -1,14 +1,19 @@
import { Routes } from '@angular/router';
import { AssortmentComponent } from './assortment.component';
import { PriceUpdateComponent } from './price-update/price-update.component';
export const routes: Routes = [
{
path: '',
component: AssortmentComponent,
children: [
{ path: 'price-update', component: PriceUpdateComponent },
{ path: '**', redirectTo: 'price-update' },
],
},
];
import { Routes } from '@angular/router';
import { AssortmentComponent } from './assortment.component';
import { PriceUpdateComponent } from './price-update/price-update.component';
export const routes: Routes = [
{
path: '',
component: AssortmentComponent,
title: 'Sortiment',
children: [
{
path: 'price-update',
component: PriceUpdateComponent,
title: 'Sortiment - Preisänderung',
},
{ path: '**', redirectTo: 'price-update' },
],
},
];

View File

@@ -1,125 +1,135 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ArticleDetailsComponent } from './article-details/article-details.component';
import { ArticleSearchComponent } from './article-search/article-search.component';
import { ArticleSearchFilterComponent } from './article-search/search-filter/search-filter.component';
import { ArticleSearchMainComponent } from './article-search/search-main/search-main.component';
import { ArticleSearchResultsComponent } from './article-search/search-results/search-results.component';
import { PageCatalogComponent } from './page-catalog.component';
import { MatomoRouteData } from 'ngx-matomo-client';
const routes: Routes = [
{
path: '',
component: PageCatalogComponent,
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: 'filter',
component: ArticleSearchFilterComponent,
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'filter/:id',
component: ArticleSearchFilterComponent,
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'search',
component: ArticleSearchComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: '',
component: ArticleSearchMainComponent,
},
],
},
{
path: 'results',
component: ArticleSearchResultsComponent,
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results',
component: ArticleSearchResultsComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results/:id',
component: ArticleSearchResultsComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails',
} as MatomoRouteData,
},
},
{
path: 'results/:ean/ean',
component: ArticleSearchResultsComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: 'details/:id',
component: ArticleDetailsComponent,
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (ID)',
} as MatomoRouteData,
},
},
{
path: 'details/:ean/ean',
component: ArticleDetailsComponent,
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: '',
pathMatch: 'full',
redirectTo: 'filter',
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class PageCatalogRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ArticleDetailsComponent } from './article-details/article-details.component';
import { ArticleSearchComponent } from './article-search/article-search.component';
import { ArticleSearchFilterComponent } from './article-search/search-filter/search-filter.component';
import { ArticleSearchMainComponent } from './article-search/search-main/search-main.component';
import { ArticleSearchResultsComponent } from './article-search/search-results/search-results.component';
import { PageCatalogComponent } from './page-catalog.component';
import { MatomoRouteData } from 'ngx-matomo-client';
const routes: Routes = [
{
path: '',
component: PageCatalogComponent,
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: 'filter',
component: ArticleSearchFilterComponent,
title: 'Artikelsuche - Filter',
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'filter/:id',
component: ArticleSearchFilterComponent,
title: 'Artikelsuche - Filter',
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'search',
component: ArticleSearchComponent,
outlet: 'side',
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: '',
component: ArticleSearchMainComponent,
},
],
},
{
path: 'results',
component: ArticleSearchResultsComponent,
title: 'Artikelsuche - Trefferliste',
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Trefferliste',
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results/:id',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Artikeldetails',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails',
} as MatomoRouteData,
},
},
{
path: 'results/:ean/ean',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Artikeldetails (EAN)',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: 'details/:id',
component: ArticleDetailsComponent,
title: 'Artikelsuche - Artikeldetails (ID)',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (ID)',
} as MatomoRouteData,
},
},
{
path: 'details/:ean/ean',
component: ArticleDetailsComponent,
title: 'Artikelsuche - Artikeldetails (EAN)',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: '',
pathMatch: 'full',
redirectTo: 'filter',
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class PageCatalogRoutingModule {}

View File

@@ -132,7 +132,7 @@
}
}
<page-shopping-cart-item
(changeItem)="changeItem($event)"
(changeItem)="showPurchasingListModal([$event.shoppingCartItem])"
(changeDummyItem)="changeDummyItem($event)"
(changeQuantity)="updateItemQuantity($event)"
[quantityError]="

View File

@@ -388,26 +388,6 @@ export class CheckoutReviewComponent
this.openDummyModal({ data: shoppingCartItem, changeDataFromCart: true });
}
async changeItem({
shoppingCartItem,
}: {
shoppingCartItem: ShoppingCartItemDTO;
}) {
const shoppingCart = await firstValueFrom(this.shoppingCart$);
const modalRef = await this._purchaseOptionsModalService.open({
tabId: this.applicationService.activatedProcessId,
shoppingCartId: shoppingCart.id,
items: [shoppingCartItem],
type: 'update',
});
await firstValueFrom(modalRef.afterClosed$);
// Reload Shopping Cart after modal is closed to get updated items
this.#checkoutService.reloadShoppingCart({
processId: this.applicationService.activatedProcessId,
});
}
async openPrintModal() {
const shoppingCart = await this.shoppingCart$.pipe(first()).toPromise();
this.uiModal.open({

View File

@@ -14,20 +14,24 @@ const routes: Routes = [
{
path: 'details',
component: CheckoutReviewDetailsComponent,
title: 'Bestelldetails',
outlet: 'side',
},
{
path: 'review',
component: CheckoutReviewComponent,
title: 'Bestellung überprüfen',
},
{
path: 'summary',
component: CheckoutSummaryComponent,
title: 'Bestellübersicht',
canDeactivate: [canDeactivateTabCleanup],
},
{
path: 'summary/:orderIds',
component: CheckoutSummaryComponent,
title: 'Bestellübersicht',
canDeactivate: [canDeactivateTabCleanup],
},
{ path: '', pathMatch: 'full', redirectTo: 'review' },

View File

@@ -9,8 +9,7 @@ import { DecimalPipe } from '@angular/common';
import { Component, Input, OnInit, inject } from '@angular/core';
import { IconComponent } from '@shared/components/icon';
import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
import { injectTabId } from '@isa/core/tabs';
import { NavigationStateService } from '@isa/core/navigation';
import { injectTabId, TabService } from '@isa/core/tabs';
import { Router } from '@angular/router';
import { CustomerSearchNavigation } from '@shared/services/navigation';
@@ -47,7 +46,7 @@ import { CustomerSearchNavigation } from '@shared/services/navigation';
export class KundenkarteComponent implements OnInit {
#tabId = injectTabId();
#router = inject(Router);
#navigationState = inject(NavigationStateService);
#tabService = inject(TabService);
#customerNavigationService = inject(CustomerSearchNavigation);
@Input() cardDetails: BonusCardInfoDTO;
@@ -69,13 +68,12 @@ export class KundenkarteComponent implements OnInit {
return;
}
this.#navigationState.preserveContext(
{
this.#tabService.patchTabMetadata(tabId, {
'select-customer': {
returnUrl: `/${tabId}/reward`,
autoTriggerContinueFn: true,
},
'select-customer',
);
});
await this.#router.navigate(
this.#customerNavigationService.detailsRoute({

View File

@@ -1,5 +1,11 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
OnDestroy,
inject,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { TabService } from '@isa/core/tabs';
import { map } from 'rxjs/operators';
@Component({
@@ -9,8 +15,17 @@ import { map } from 'rxjs/operators';
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class CustomerComponent {
export class CustomerComponent implements OnDestroy {
private _tabService = inject(TabService);
processId$ = this._activatedRoute.data.pipe(map((data) => data.processId));
constructor(private _activatedRoute: ActivatedRoute) {}
ngOnDestroy() {
const tab = this._tabService.activatedTab();
// #5512 Always clear preserved select-customer context if navigating out of customer area
this._tabService.patchTabMetadata(tab.id, {
'select-customer': null,
});
}
}

View File

@@ -166,6 +166,11 @@ export class DetailsMainViewBillingAddressesComponent
customer as unknown as Customer,
),
);
// Clear the selected payer ID when using customer address
this.crmTabMetadataService.setSelectedPayerId(
this.tabId(),
undefined,
);
}
});
}

View File

@@ -191,6 +191,11 @@ export class DetailsMainViewDeliveryAddressesComponent
customer as unknown as Customer,
),
);
// Clear the selected shipping address ID when using customer address
this.crmTabMetadataService.setSelectedShippingAddressId(
this.tabId(),
undefined,
);
}
});
}

View File

@@ -49,9 +49,14 @@ import {
NavigateAfterRewardSelection,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
import { NavigationStateService } from '@isa/core/navigation';
import { TabService } from '@isa/core/tabs';
import { ShippingAddressDTO as CrmShippingAddressDTO } from '@generated/swagger/crm-api';
interface SelectCustomerContext {
returnUrl?: string;
autoTriggerContinueFn?: boolean;
}
export interface CustomerDetailsViewMainState {
isBusy: boolean;
shoppingCart: ShoppingCartDTO;
@@ -80,7 +85,7 @@ export class CustomerDetailsViewMainComponent
private _router = inject(Router);
private _activatedRoute = inject(ActivatedRoute);
private _genderSettings = inject(GenderSettingsService);
private _navigationState = inject(NavigationStateService);
private _tabService = inject(TabService);
private _onDestroy$ = new Subject<void>();
customerService = inject(CrmCustomerService);
@@ -97,18 +102,19 @@ export class CustomerDetailsViewMainComponent
map(([fetchingCustomer, fetchingList]) => fetchingCustomer || fetchingList),
);
async getReturnUrlFromContext(): Promise<string | null> {
// Get from preserved context (survives intermediate navigations, auto-scoped to tab)
const context = await this._navigationState.restoreContext<{
returnUrl?: string;
}>('select-customer');
getReturnUrlFromContext(): string | null {
// Get from preserved context (survives intermediate navigations, scoped to tab)
const context = this._tabService.activatedTab()?.metadata?.[
'select-customer'
] as SelectCustomerContext | undefined;
return context?.returnUrl ?? null;
}
async checkHasReturnUrl(): Promise<void> {
const hasContext =
await this._navigationState.hasPreservedContext('select-customer');
checkHasReturnUrl(): void {
const hasContext = !!this._tabService.activatedTab()?.metadata?.[
'select-customer'
];
this.hasReturnUrl.set(hasContext);
}
@@ -321,24 +327,23 @@ export class CustomerDetailsViewMainComponent
ngOnInit() {
// Check if we have a return URL context
this.checkHasReturnUrl().then(async () => {
// Check if we should auto-trigger continue() (only from Kundenkarte)
const context = await this._navigationState.restoreContext<{
returnUrl?: string;
autoTriggerContinueFn?: boolean;
}>('select-customer');
this.checkHasReturnUrl();
if (context?.autoTriggerContinueFn) {
// Clear the autoTriggerContinueFn flag immediately (preserves returnUrl automatically)
await this._navigationState.patchContext(
{ autoTriggerContinueFn: undefined },
'select-customer',
);
// Check if we should auto-trigger continue() (only from Kundenkarte)
const tab = this._tabService.activatedTab();
const context = tab?.metadata?.['select-customer'] as
| SelectCustomerContext
| undefined;
// Auto-trigger continue() ONLY when coming from Kundenkarte
this.continue();
}
});
if (context?.autoTriggerContinueFn && tab) {
// Clear the autoTriggerContinueFn flag immediately (preserves returnUrl)
this._tabService.patchTabMetadata(tab.id, {
'select-customer': { ...context, autoTriggerContinueFn: undefined },
});
// Auto-trigger continue() ONLY when coming from Kundenkarte
this.continue();
}
this.processId$
.pipe(
@@ -436,10 +441,18 @@ export class CustomerDetailsViewMainComponent
// #5262 Check for reward selection flow before navigation
if (this.hasReturnUrl()) {
// Restore from preserved context (auto-scoped to current tab) and clean up
const context = await this._navigationState.restoreAndClearContext<{
returnUrl?: string;
}>('select-customer');
// Restore from preserved context (scoped to current tab) and clean up
const tab = this._tabService.activatedTab();
const context = tab?.metadata?.['select-customer'] as
| SelectCustomerContext
| undefined;
// Clear the context
if (tab) {
this._tabService.patchTabMetadata(tab.id, {
'select-customer': null,
});
}
if (context?.returnUrl) {
await this._router.navigateByUrl(context.returnUrl);

View File

@@ -10,7 +10,7 @@ import {
import { CustomerSearchStore } from '../store';
import { ActivatedRoute, Router } from '@angular/router';
import { map } from 'rxjs/operators';
import { NavigationStateService } from '@isa/core/navigation';
import { TabService } from '@isa/core/tabs';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { AsyncPipe } from '@angular/common';
import { CustomerMenuComponent } from '../../components/customer-menu';
@@ -51,7 +51,7 @@ export class KundenkarteMainViewComponent implements OnDestroy {
#cardTransactionsResource = inject(CustomerCardTransactionsResource);
elementRef = inject(ElementRef);
#router = inject(Router);
#navigationState = inject(NavigationStateService);
#tabService = inject(TabService);
#customerNavigationService = inject(CustomerSearchNavigation);
/**
@@ -120,13 +120,12 @@ export class KundenkarteMainViewComponent implements OnDestroy {
}
// Preserve context for auto-triggering continue() in details view
this.#navigationState.preserveContext(
{
this.#tabService.patchTabMetadata(tabId, {
'select-customer': {
returnUrl: `/${tabId}/reward`,
autoTriggerContinueFn: true,
},
'select-customer',
);
});
// Navigate to customer details - will auto-trigger continue()
await this.#router.navigate(

View File

@@ -1,170 +1,187 @@
import { Routes } from '@angular/router';
import { CustomerComponent } from './customer-page.component';
import { CustomerSearchComponent } from './customer-search/customer-search.component';
import { CustomerResultsMainViewComponent } from './customer-search/results-main-view/results-main-view.component';
import { CustomerDetailsViewMainComponent } from './customer-search/details-main-view/details-main-view.component';
import { CustomerHistoryMainViewComponent } from './customer-search/history-main-view/history-main-view.component';
import { CustomerFilterMainViewComponent } from './customer-search/filter-main-view/filter-main-view.component';
import { CustomerCreateGuard } from './guards/customer-create.guard';
import {
CreateB2BCustomerComponent,
CreateGuestCustomerComponent,
// CreateP4MCustomerComponent,
CreateStoreCustomerComponent,
CreateWebshopCustomerComponent,
} from './create-customer';
// import { UpdateP4MWebshopCustomerComponent } from './create-customer/update-p4m-webshop-customer';
import { CreateCustomerComponent } from './create-customer/create-customer.component';
import { CustomerDataEditB2BComponent } from './customer-search/edit-main-view/customer-data-edit-b2b.component';
import { CustomerDataEditB2CComponent } from './customer-search/edit-main-view/customer-data-edit-b2c.component';
import { AddBillingAddressMainViewComponent } from './customer-search/add-billing-address-main-view/add-billing-address-main-view.component';
import { AddShippingAddressMainViewComponent } from './customer-search/add-shipping-address-main-view/add-shipping-address-main-view.component';
import { EditBillingAddressMainViewComponent } from './customer-search/edit-billing-address-main-view/edit-billing-address-main-view.component';
import { EditShippingAddressMainViewComponent } from './customer-search/edit-shipping-address-main-view/edit-shipping-address-main-view.component';
import { CustomerOrdersMainViewComponent } from './customer-search/orders-main-view/orders-main-view.component';
import { OrderDetailsMainViewComponent } from './customer-search/order-details-main-view/order-details-main-view.component';
import { KundenkarteMainViewComponent } from './customer-search/kundenkarte-main-view/kundenkarte-main-view.component';
import { CustomerOrderDetailsHistoryMainViewComponent } from './customer-search/order-details-history-main-view/order-details-history-main-view.component';
import { CustomerMainViewComponent } from './customer-search/main-view/main-view.component';
import { MainSideViewComponent } from './customer-search/main-side-view/main-side-view.component';
import { CustomerResultsSideViewComponent } from './customer-search/results-side-view/results-side-view.component';
import { OrderDetailsSideViewComponent } from './customer-search/order-details-side-view/order-details-side-view.component';
import { CustomerCreateSideViewComponent } from './create-customer/customer-create-side-view';
export const routes: Routes = [
{
path: '',
component: CustomerComponent,
children: [
{
path: '',
component: CustomerSearchComponent,
children: [
{
path: 'search',
component: CustomerMainViewComponent,
data: { side: 'main', breadcrumb: 'main' },
},
{
path: 'search/list',
component: CustomerResultsMainViewComponent,
data: { breadcrumb: 'search' },
},
{
path: 'search/filter',
component: CustomerFilterMainViewComponent,
data: { side: 'results', breadcrumb: 'filter' },
},
{
path: 'search/:customerId',
component: CustomerDetailsViewMainComponent,
data: { side: 'results', breadcrumb: 'details' },
},
{
path: 'search/:customerId/history',
component: CustomerHistoryMainViewComponent,
data: { side: 'results', breadcrumb: 'history' },
},
{
path: 'search/:customerId/kundenkarte',
component: KundenkarteMainViewComponent,
data: { side: 'results', breadcrumb: 'kundenkarte' },
},
{
path: 'search/:customerId/orders',
component: CustomerOrdersMainViewComponent,
data: { side: 'results', breadcrumb: 'orders' },
},
{
path: 'search/:customerId/orders/:orderId',
component: OrderDetailsMainViewComponent,
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId',
component: OrderDetailsMainViewComponent,
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId/history',
component: CustomerOrderDetailsHistoryMainViewComponent,
data: {
side: 'order-details',
breadcrumb: 'order-details-history',
},
},
{
path: 'search/:customerId/edit/b2b',
component: CustomerDataEditB2BComponent,
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/edit',
component: CustomerDataEditB2CComponent,
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/billingaddress/add',
component: AddBillingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'add-billing-address' },
},
{
path: 'search/:customerId/billingaddress/:payerId/edit',
component: EditBillingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'edit-billing-address' },
},
{
path: 'search/:customerId/shippingaddress/add',
component: AddShippingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'add-shipping-address' },
},
{
path: 'search/:customerId/shippingaddress/:shippingAddressId/edit',
component: EditShippingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'edit-shipping-address' },
},
{
path: 'search-customer-main',
outlet: 'side',
component: MainSideViewComponent,
},
{
path: 'results',
outlet: 'side',
component: CustomerResultsSideViewComponent,
},
{
path: 'order-details',
outlet: 'side',
component: OrderDetailsSideViewComponent,
},
],
},
{
path: '',
component: CreateCustomerComponent,
canActivate: [CustomerCreateGuard],
canActivateChild: [CustomerCreateGuard],
children: [
{ path: 'create', component: CreateStoreCustomerComponent },
{ path: 'create/store', component: CreateStoreCustomerComponent },
{ path: 'create/webshop', component: CreateWebshopCustomerComponent },
{ path: 'create/b2b', component: CreateB2BCustomerComponent },
{ path: 'create/guest', component: CreateGuestCustomerComponent },
// { path: 'create/webshop-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'webshop' } },
// { path: 'create/store-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'store' } },
// {
// path: 'create/webshop-p4m/update',
// component: UpdateP4MWebshopCustomerComponent,
// data: { customerType: 'webshop' },
// },
{
path: 'create-customer-main',
outlet: 'side',
component: CustomerCreateSideViewComponent,
},
],
},
],
},
];
import { Routes } from '@angular/router';
import { CustomerComponent } from './customer-page.component';
import { CustomerSearchComponent } from './customer-search/customer-search.component';
import { CustomerResultsMainViewComponent } from './customer-search/results-main-view/results-main-view.component';
import { CustomerDetailsViewMainComponent } from './customer-search/details-main-view/details-main-view.component';
import { CustomerHistoryMainViewComponent } from './customer-search/history-main-view/history-main-view.component';
import { CustomerFilterMainViewComponent } from './customer-search/filter-main-view/filter-main-view.component';
import { CustomerCreateGuard } from './guards/customer-create.guard';
import {
CreateB2BCustomerComponent,
CreateGuestCustomerComponent,
// CreateP4MCustomerComponent,
CreateStoreCustomerComponent,
CreateWebshopCustomerComponent,
} from './create-customer';
// import { UpdateP4MWebshopCustomerComponent } from './create-customer/update-p4m-webshop-customer';
import { CreateCustomerComponent } from './create-customer/create-customer.component';
import { CustomerDataEditB2BComponent } from './customer-search/edit-main-view/customer-data-edit-b2b.component';
import { CustomerDataEditB2CComponent } from './customer-search/edit-main-view/customer-data-edit-b2c.component';
import { AddBillingAddressMainViewComponent } from './customer-search/add-billing-address-main-view/add-billing-address-main-view.component';
import { AddShippingAddressMainViewComponent } from './customer-search/add-shipping-address-main-view/add-shipping-address-main-view.component';
import { EditBillingAddressMainViewComponent } from './customer-search/edit-billing-address-main-view/edit-billing-address-main-view.component';
import { EditShippingAddressMainViewComponent } from './customer-search/edit-shipping-address-main-view/edit-shipping-address-main-view.component';
import { CustomerOrdersMainViewComponent } from './customer-search/orders-main-view/orders-main-view.component';
import { OrderDetailsMainViewComponent } from './customer-search/order-details-main-view/order-details-main-view.component';
import { KundenkarteMainViewComponent } from './customer-search/kundenkarte-main-view/kundenkarte-main-view.component';
import { CustomerOrderDetailsHistoryMainViewComponent } from './customer-search/order-details-history-main-view/order-details-history-main-view.component';
import { CustomerMainViewComponent } from './customer-search/main-view/main-view.component';
import { MainSideViewComponent } from './customer-search/main-side-view/main-side-view.component';
import { CustomerResultsSideViewComponent } from './customer-search/results-side-view/results-side-view.component';
import { OrderDetailsSideViewComponent } from './customer-search/order-details-side-view/order-details-side-view.component';
import { CustomerCreateSideViewComponent } from './create-customer/customer-create-side-view';
export const routes: Routes = [
{
path: '',
component: CustomerComponent,
children: [
{
path: '',
component: CustomerSearchComponent,
children: [
{
path: 'search',
component: CustomerMainViewComponent,
title: 'Kundensuche',
data: { side: 'main', breadcrumb: 'main' },
},
{
path: 'search/list',
component: CustomerResultsMainViewComponent,
title: 'Kundensuche - Trefferliste',
data: { breadcrumb: 'search' },
},
{
path: 'search/filter',
component: CustomerFilterMainViewComponent,
title: 'Kundensuche - Filter',
data: { side: 'results', breadcrumb: 'filter' },
},
{
path: 'search/:customerId',
component: CustomerDetailsViewMainComponent,
title: 'Kundendetails',
data: { side: 'results', breadcrumb: 'details' },
},
{
path: 'search/:customerId/history',
component: CustomerHistoryMainViewComponent,
title: 'Kundendetails - Verlauf',
data: { side: 'results', breadcrumb: 'history' },
},
{
path: 'search/:customerId/kundenkarte',
component: KundenkarteMainViewComponent,
title: 'Kundendetails - Kundenkarte',
data: { side: 'results', breadcrumb: 'kundenkarte' },
},
{
path: 'search/:customerId/orders',
component: CustomerOrdersMainViewComponent,
title: 'Kundendetails - Bestellungen',
data: { side: 'results', breadcrumb: 'orders' },
},
{
path: 'search/:customerId/orders/:orderId',
component: OrderDetailsMainViewComponent,
title: 'Kundendetails - Bestelldetails',
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId',
component: OrderDetailsMainViewComponent,
title: 'Kundendetails - Bestelldetails',
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId/history',
component: CustomerOrderDetailsHistoryMainViewComponent,
title: 'Kundendetails - Bestelldetails Verlauf',
data: {
side: 'order-details',
breadcrumb: 'order-details-history',
},
},
{
path: 'search/:customerId/edit/b2b',
component: CustomerDataEditB2BComponent,
title: 'Kundendetails - Bearbeiten (B2B)',
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/edit',
component: CustomerDataEditB2CComponent,
title: 'Kundendetails - Bearbeiten',
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/billingaddress/add',
component: AddBillingAddressMainViewComponent,
title: 'Kundendetails - Neue Rechnungsadresse',
data: { side: 'results', breadcrumb: 'add-billing-address' },
},
{
path: 'search/:customerId/billingaddress/:payerId/edit',
component: EditBillingAddressMainViewComponent,
title: 'Kundendetails - Rechnungsadresse bearbeiten',
data: { side: 'results', breadcrumb: 'edit-billing-address' },
},
{
path: 'search/:customerId/shippingaddress/add',
component: AddShippingAddressMainViewComponent,
title: 'Kundendetails - Neue Lieferadresse',
data: { side: 'results', breadcrumb: 'add-shipping-address' },
},
{
path: 'search/:customerId/shippingaddress/:shippingAddressId/edit',
component: EditShippingAddressMainViewComponent,
title: 'Kundendetails - Lieferadresse bearbeiten',
data: { side: 'results', breadcrumb: 'edit-shipping-address' },
},
{
path: 'search-customer-main',
outlet: 'side',
component: MainSideViewComponent,
},
{
path: 'results',
outlet: 'side',
component: CustomerResultsSideViewComponent,
},
{
path: 'order-details',
outlet: 'side',
component: OrderDetailsSideViewComponent,
},
],
},
{
path: '',
component: CreateCustomerComponent,
canActivate: [CustomerCreateGuard],
canActivateChild: [CustomerCreateGuard],
title: 'Kundendaten erfassen',
children: [
{ path: 'create', component: CreateStoreCustomerComponent },
{ path: 'create/store', component: CreateStoreCustomerComponent },
{ path: 'create/webshop', component: CreateWebshopCustomerComponent },
{ path: 'create/b2b', component: CreateB2BCustomerComponent },
{ path: 'create/guest', component: CreateGuestCustomerComponent },
// { path: 'create/webshop-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'webshop' } },
// { path: 'create/store-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'store' } },
// {
// path: 'create/webshop-p4m/update',
// component: UpdateP4MWebshopCustomerComponent,
// data: { customerType: 'webshop' },
// },
{
path: 'create-customer-main',
outlet: 'side',
component: CustomerCreateSideViewComponent,
},
],
},
],
},
];

View File

@@ -1,16 +1,17 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DashboardComponent } from './dashboard.component';
const routes: Routes = [
{
path: '',
component: DashboardComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DashboardRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DashboardComponent } from './dashboard.component';
const routes: Routes = [
{
path: '',
component: DashboardComponent,
title: 'Dashboard',
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DashboardRoutingModule {}

View File

@@ -1,35 +1,51 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { GoodsInCleanupListComponent } from './goods-in-cleanup-list/goods-in-cleanup-list.component';
import { GoodsInCleanupListModule } from './goods-in-cleanup-list/goods-in-cleanup-list.module';
import { GoodsInListComponent } from './goods-in-list/goods-in-list.component';
import { GoodsInListModule } from './goods-in-list/goods-in-list.module';
import { GoodsInRemissionPreviewComponent } from './goods-in-remission-preview/goods-in-remission-preview.component';
import { GoodsInRemissionPreviewModule } from './goods-in-remission-preview/goods-in-remission-preview.module';
import { GoodsInReservationComponent } from './goods-in-reservation/goods-in-reservation.component';
import { GoodsInReservationModule } from './goods-in-reservation/goods-in-reservation.module';
import { GoodsInComponent } from './goods-in.component';
const routes: Routes = [
{
path: '',
component: GoodsInComponent,
children: [
{ path: 'list', component: GoodsInListComponent },
{ path: 'reservation', component: GoodsInReservationComponent },
{ path: 'cleanup', component: GoodsInCleanupListComponent },
{ path: 'preview', component: GoodsInRemissionPreviewComponent },
],
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
GoodsInListModule,
GoodsInCleanupListModule,
GoodsInReservationModule,
GoodsInRemissionPreviewModule,
],
exports: [RouterModule],
})
export class GoodsInRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { GoodsInCleanupListComponent } from './goods-in-cleanup-list/goods-in-cleanup-list.component';
import { GoodsInCleanupListModule } from './goods-in-cleanup-list/goods-in-cleanup-list.module';
import { GoodsInListComponent } from './goods-in-list/goods-in-list.component';
import { GoodsInListModule } from './goods-in-list/goods-in-list.module';
import { GoodsInRemissionPreviewComponent } from './goods-in-remission-preview/goods-in-remission-preview.component';
import { GoodsInRemissionPreviewModule } from './goods-in-remission-preview/goods-in-remission-preview.module';
import { GoodsInReservationComponent } from './goods-in-reservation/goods-in-reservation.component';
import { GoodsInReservationModule } from './goods-in-reservation/goods-in-reservation.module';
import { GoodsInComponent } from './goods-in.component';
const routes: Routes = [
{
path: '',
component: GoodsInComponent,
children: [
{
path: 'list',
component: GoodsInListComponent,
title: 'Abholfach - Fehlende',
},
{
path: 'reservation',
component: GoodsInReservationComponent,
title: 'Abholfach - Reservierung',
},
{
path: 'cleanup',
component: GoodsInCleanupListComponent,
title: 'Abholfach - Ausräumen',
},
{
path: 'preview',
component: GoodsInRemissionPreviewComponent,
title: 'Abholfach - Vorschau',
},
],
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
GoodsInListModule,
GoodsInCleanupListModule,
GoodsInReservationModule,
GoodsInRemissionPreviewModule,
],
exports: [RouterModule],
})
export class GoodsInRoutingModule {}

View File

@@ -1,22 +1,24 @@
import { Routes } from '@angular/router';
import { PackageDetailsComponent } from './package-details';
import { PackageInspectionComponent } from './package-inspection.component';
import { PackageResultComponent } from './package-result';
export const packageInspectionRoutes: Routes = [
{
path: '',
component: PackageInspectionComponent,
children: [
{
path: 'packages',
component: PackageResultComponent,
},
{
path: 'packages/:id',
component: PackageDetailsComponent,
},
{ path: '**', redirectTo: 'packages' },
],
},
];
import { Routes } from '@angular/router';
import { PackageDetailsComponent } from './package-details';
import { PackageInspectionComponent } from './package-inspection.component';
import { PackageResultComponent } from './package-result';
export const packageInspectionRoutes: Routes = [
{
path: '',
component: PackageInspectionComponent,
title: 'Packstück-Prüfung',
children: [
{
path: 'packages',
component: PackageResultComponent,
},
{
path: 'packages/:id',
component: PackageDetailsComponent,
title: 'Packstück-Prüfung - Details',
},
{ path: '**', redirectTo: 'packages' },
],
},
];

View File

@@ -1,146 +1,168 @@
import { Routes } from '@angular/router';
import { PickupShelfInComponent } from './pickup-shelf-in.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfInDetailsComponent } from './pickup-shelf-in-details/pickup-shelf-in-details.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickUpShelfInMainSideViewComponent } from './pickup-shelf-in-main-side-view/pickup-shelf-in-main-side-view.component';
import { PickUpShelfInMainComponent } from './pickup-shelf-in-main/pickup-shelf-in-main.component';
import { PickUpShelfInListComponent } from './pickup-shelf-in-list/pickup-shelf-in-list.component';
import { PickupShelfInEditComponent } from './pickup-shelf-in-edit/pickup-shelf-in-edit.component';
import { MatomoRouteData } from 'ngx-matomo-client';
export const routes: Routes = [
{
path: '',
component: PickupShelfInComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
children: [
{
path: 'main',
component: PickUpShelfInMainComponent,
data: {
view: 'main',
matomo: {
title: 'Abholfach',
} as MatomoRouteData,
},
},
{
path: 'list',
component: PickUpShelfInListComponent,
data: {
view: 'list',
matomo: {
title: 'Abholfach - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'list/filter',
component: PickupShelfFilterComponent,
data: {
view: 'filter',
matomo: {
title: 'Abholfach - Filter',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{ path: 'search', component: PickUpShelfInMainSideViewComponent, outlet: 'side' },
{ path: 'list', component: PickUpShelfInListComponent, data: { view: 'list' }, outlet: 'side' },
],
},
];
import { Routes } from '@angular/router';
import { PickupShelfInComponent } from './pickup-shelf-in.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfInDetailsComponent } from './pickup-shelf-in-details/pickup-shelf-in-details.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickUpShelfInMainSideViewComponent } from './pickup-shelf-in-main-side-view/pickup-shelf-in-main-side-view.component';
import { PickUpShelfInMainComponent } from './pickup-shelf-in-main/pickup-shelf-in-main.component';
import { PickUpShelfInListComponent } from './pickup-shelf-in-list/pickup-shelf-in-list.component';
import { PickupShelfInEditComponent } from './pickup-shelf-in-edit/pickup-shelf-in-edit.component';
import { MatomoRouteData } from 'ngx-matomo-client';
export const routes: Routes = [
{
path: '',
component: PickupShelfInComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
title: 'Abholfach - Einbuchen',
children: [
{
path: 'main',
component: PickUpShelfInMainComponent,
data: {
view: 'main',
matomo: {
title: 'Abholfach',
} as MatomoRouteData,
},
},
{
path: 'list',
component: PickUpShelfInListComponent,
title: 'Abholfach - Trefferliste',
data: {
view: 'list',
matomo: {
title: 'Abholfach - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'list/filter',
component: PickupShelfFilterComponent,
title: 'Abholfach - Filter',
data: {
view: 'filter',
matomo: {
title: 'Abholfach - Filter',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
title: 'Abholfach - Bearbeiten',
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
title: 'Abholfach - Bearbeiten',
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
title: 'Abholfach - Bearbeiten',
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
title: 'Abholfach - Historie',
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
title: 'Abholfach - Historie',
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
title: 'Abholfach - Historie',
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
title: 'Abholfach - Details',
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
title: 'Abholfach - Details',
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
title: 'Abholfach - Details',
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'search',
component: PickUpShelfInMainSideViewComponent,
outlet: 'side',
},
{
path: 'list',
component: PickUpShelfInListComponent,
data: { view: 'list' },
outlet: 'side',
},
],
},
];

View File

@@ -1,75 +1,107 @@
import { Routes } from '@angular/router';
import { PickupShelfOutComponent } from './pickup-shelf-out.component';
import { PickupShelfOutMainComponent } from './pickup-shelf-out-main/pickup-shelf-out-main.component';
import { PickupShelfOutListComponent } from './pickup-shelf-out-list/pickup-shelf-out-list.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfOutDetailsComponent } from './pickup-shelf-out-details/pickup-shelf-out-details.component';
import { PickupShelfOutMainSideViewComponent } from './pickup-shelf-out-main-side-view/pickup-shelf-out-main-side-view.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickupShelfOutEditComponent } from './pickup-shelf-out-edit/pickup-shelf-out-edit.component';
export const routes: Routes = [
{
path: '',
component: PickupShelfOutComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
children: [
{ path: 'main', component: PickupShelfOutMainComponent, data: { view: 'main' } },
{ path: 'list', component: PickupShelfOutListComponent, data: { view: 'list' } },
{ path: 'list/filter', component: PickupShelfFilterComponent, data: { view: 'filter' } },
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
data: { view: 'edit' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
data: { view: 'history' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
data: { view: 'details' },
},
{ path: 'search', component: PickupShelfOutMainSideViewComponent, outlet: 'side' },
{ path: 'list', component: PickupShelfOutListComponent, data: { view: 'list' }, outlet: 'side' },
],
},
];
import { Routes } from '@angular/router';
import { PickupShelfOutComponent } from './pickup-shelf-out.component';
import { PickupShelfOutMainComponent } from './pickup-shelf-out-main/pickup-shelf-out-main.component';
import { PickupShelfOutListComponent } from './pickup-shelf-out-list/pickup-shelf-out-list.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfOutDetailsComponent } from './pickup-shelf-out-details/pickup-shelf-out-details.component';
import { PickupShelfOutMainSideViewComponent } from './pickup-shelf-out-main-side-view/pickup-shelf-out-main-side-view.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickupShelfOutEditComponent } from './pickup-shelf-out-edit/pickup-shelf-out-edit.component';
export const routes: Routes = [
{
path: '',
component: PickupShelfOutComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
title: 'Warenausgabe',
children: [
{
path: 'main',
component: PickupShelfOutMainComponent,
data: { view: 'main' },
},
{
path: 'list',
component: PickupShelfOutListComponent,
data: { view: 'list' },
},
{
path: 'list/filter',
component: PickupShelfFilterComponent,
data: { view: 'filter' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
title: 'Warenausgabe - Details',
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
title: 'Warenausgabe - Details',
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
title: 'Warenausgabe - Details',
data: { view: 'edit' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
title: 'Warenausgabe - Verlauf',
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
title: 'Warenausgabe - Verlauf',
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
title: 'Warenausgabe - Verlauf',
data: { view: 'history' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
title: 'Warenausgabe - Details',
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
title: 'Warenausgabe - Details',
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
title: 'Warenausgabe - Details',
data: { view: 'details' },
},
{
path: 'search',
component: PickupShelfOutMainSideViewComponent,
outlet: 'side',
data: { view: 'search' },
},
{
path: 'list',
component: PickupShelfOutListComponent,
data: { view: 'list' },
outlet: 'side',
},
],
},
];

View File

@@ -1,27 +1,36 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PageTaskCalendarComponent } from './page-task-calendar.component';
import { CalendarComponent } from './pages/calendar/calendar.component';
import { CalendarModule } from './pages/calendar/calendar.module';
import { TaskSearchComponent } from './pages/task-search';
import { TasksComponent } from './pages/tasks/tasks.component';
import { TasksModule } from './pages/tasks/tasks.module';
const routes: Routes = [
{
path: '',
component: PageTaskCalendarComponent,
children: [
{ path: 'calendar', component: CalendarComponent },
{ path: 'tasks', component: TasksComponent },
{ path: 'search', component: TaskSearchComponent },
{ path: '', pathMatch: 'full', redirectTo: 'tasks' },
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes), CalendarModule, TasksModule],
exports: [RouterModule],
})
export class PageTaskCalendarRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PageTaskCalendarComponent } from './page-task-calendar.component';
import { CalendarComponent } from './pages/calendar/calendar.component';
import { CalendarModule } from './pages/calendar/calendar.module';
import { TaskSearchComponent } from './pages/task-search';
import { TasksComponent } from './pages/tasks/tasks.component';
import { TasksModule } from './pages/tasks/tasks.module';
const routes: Routes = [
{
path: '',
component: PageTaskCalendarComponent,
title: 'Tätigkeitskalender',
children: [
{ path: 'calendar', component: CalendarComponent },
{
path: 'tasks',
title: 'Tätigkeitskalender - Aufgaben',
component: TasksComponent,
},
{
path: 'search',
title: 'Tätigkeitskalender - Suche',
component: TaskSearchComponent,
},
{ path: '', pathMatch: 'full', redirectTo: 'tasks' },
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes), CalendarModule, TasksModule],
exports: [RouterModule],
})
export class PageTaskCalendarRoutingModule {}

View File

@@ -1,16 +1,43 @@
@if (modalRef.data.subtitle; as subtitle) {
<h2 class="subtitle">{{ subtitle }}</h2>
}
@if (modalRef.data.content; as content) {
<p class="content">
{{ content }}
</p>
<!-- QR Code Display Mode -->
@if (shouldShowQrCode(); as showQr) {
@if (parsedContent(); as parsed) {
@if (parsed.textBefore) {
<p class="content">{{ parsed.textBefore }}</p>
}
<div class="qr-code-container">
<qrcode
[qrdata]="parsed.url!"
[width]="200"
[errorCorrectionLevel]="'M'"
[margin]="2"
></qrcode>
</div>
@if (parsed.textAfter) {
<p class="content">{{ parsed.textAfter }}</p>
}
}
} @else {
<!-- Default Text Display Mode -->
@if (modalRef.data.content; as content) {
<p class="content">
{{ content }}
</p>
}
}
@if (modalRef.data.actions; as actions) {
<div class="actions">
@for (action of actions; track action) {
<button [class.selected]="action.selected" (click)="handleCommand(action.command)">
<button
[class.selected]="action.selected"
(click)="handleCommand(action.command)"
>
{{ action.label }}
</button>
}

View File

@@ -15,11 +15,15 @@
@apply text-lg text-center whitespace-pre-wrap mb-8 px-16;
}
.qr-code-container {
@apply flex flex-col items-center justify-center mb-8;
}
.actions {
@apply text-center mb-8;
button {
@apply border-2 border-solid border-brand bg-white text-brand rounded-full py-3 px-6 font-bold text-lg outline-none self-end whitespace-nowrap ml-4;
@apply border-2 border-solid border-brand bg-white text-brand rounded-full py-3 px-6 font-bold text-lg outline-none self-end whitespace-nowrap;
&.selected {
@apply bg-brand text-white;

View File

@@ -1,7 +1,8 @@
import { Component, OnInit } from '@angular/core';
import { Component, computed, OnInit } from '@angular/core';
import { CommandService } from '@core/command';
import { UiModalRef } from '../defs/modal-ref';
import { DialogModel } from './dialog.model';
import { parseDialogContentForUrl } from './dialog.helper';
@Component({
selector: 'ui-dialog-modal',
@@ -10,6 +11,26 @@ import { DialogModel } from './dialog.model';
standalone: false,
})
export class UiDialogModalComponent implements OnInit {
/**
* Parsed content with URL extracted for QR code display.
* Only relevant when showUrlAsQrCode is true.
*/
readonly parsedContent = computed(() => {
const data = this.modalRef.data;
if (!data.showUrlAsQrCode) {
return null;
}
return parseDialogContentForUrl(data.content);
});
/**
* Whether to show the QR code instead of the URL text.
*/
readonly shouldShowQrCode = computed(() => {
const parsed = this.parsedContent();
return parsed !== null && parsed.url !== null;
});
constructor(
public modalRef: UiModalRef<any, DialogModel<any>>,
private _command: CommandService,

View File

@@ -0,0 +1,48 @@
import { ParsedDialogContent } from './dialog.model';
/**
* Regular expression to match URLs in text.
* Matches http:// and https:// URLs.
*/
const URL_REGEX = /https?:\/\/[^\s]+/i;
/**
* Parses the dialog content and extracts the first URL.
* Splits the content into text before the URL, the URL itself, and text after.
*
* @param content - The dialog content string to parse
* @returns ParsedDialogContent with the split content
*/
export const parseDialogContentForUrl = (
content: string | undefined,
): ParsedDialogContent => {
if (!content) {
return { textBefore: '', url: null, textAfter: '' };
}
const match = content.match(URL_REGEX);
if (!match || match.index === undefined) {
return { textBefore: content, url: null, textAfter: '' };
}
const url = match[0];
const urlIndex = match.index;
const textBefore = content.substring(0, urlIndex).trim();
const textAfter = content.substring(urlIndex + url.length).trim();
return { textBefore, url, textAfter };
};
/**
* Checks if the given content contains a URL.
*
* @param content - The content string to check
* @returns true if a URL is found, false otherwise
*/
export const contentHasUrl = (content: string | undefined): boolean => {
if (!content) {
return false;
}
return URL_REGEX.test(content);
};

View File

@@ -0,0 +1,152 @@
import { contentHasUrl, parseDialogContentForUrl } from './dialog.helper';
import { ParsedDialogContent } from './dialog.model';
describe('parseDialogContentForUrl', () => {
it('should return empty result for undefined content', () => {
const result = parseDialogContentForUrl(undefined);
expect(result).toEqual<ParsedDialogContent>({
textBefore: '',
url: null,
textAfter: '',
});
});
it('should return empty result for empty string', () => {
const result = parseDialogContentForUrl('');
expect(result).toEqual<ParsedDialogContent>({
textBefore: '',
url: null,
textAfter: '',
});
});
it('should return content as textBefore when no URL is found', () => {
const content = 'This is some text without a URL';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: content,
url: null,
textAfter: '',
});
});
it('should extract https URL from content', () => {
const content = 'Text before https://example.com text after';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'Text before',
url: 'https://example.com',
textAfter: 'text after',
});
});
it('should extract http URL from content', () => {
const content = 'Text before http://example.com text after';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'Text before',
url: 'http://example.com',
textAfter: 'text after',
});
});
it('should handle URL at the beginning of content', () => {
const content = 'https://example.com/path text after';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: '',
url: 'https://example.com/path',
textAfter: 'text after',
});
});
it('should handle URL at the end of content', () => {
const content = 'Text before https://example.com/path';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'Text before',
url: 'https://example.com/path',
textAfter: '',
});
});
it('should handle real-world content with newlines', () => {
const content = `Punkte: 80500
Um alle Vorteile der Kundenkarte nutzen zu können, ist eine Verknüpfung zu einem Online-Konto notwendig. Kund:innen können sich über den QR-Code selbstständig anmelden oder die Kundenkarte dem bestehendem Konto hinzufügen. Bereits gesammelte Punkte werden übernommen.
https://h-k.me/QOHNTFVA`;
const result = parseDialogContentForUrl(content);
expect(result.url).toBe('https://h-k.me/QOHNTFVA');
expect(result.textBefore).toContain('Punkte: 80500');
expect(result.textBefore).toContain(
'Bereits gesammelte Punkte werden übernommen.',
);
expect(result.textAfter).toBe('');
});
it('should extract only the first URL when multiple URLs are present', () => {
const content = 'First https://first.com then https://second.com';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'First',
url: 'https://first.com',
textAfter: 'then https://second.com',
});
});
it('should handle URLs with paths and query parameters', () => {
const content =
'Visit https://example.com/path?query=value&foo=bar for more';
const result = parseDialogContentForUrl(content);
expect(result.url).toBe('https://example.com/path?query=value&foo=bar');
expect(result.textBefore).toBe('Visit');
expect(result.textAfter).toBe('for more');
});
});
describe('contentHasUrl', () => {
it('should return false for undefined content', () => {
expect(contentHasUrl(undefined)).toBe(false);
});
it('should return false for empty string', () => {
expect(contentHasUrl('')).toBe(false);
});
it('should return false for content without URL', () => {
expect(contentHasUrl('This is text without a URL')).toBe(false);
});
it('should return true for content with https URL', () => {
expect(contentHasUrl('Check out https://example.com')).toBe(true);
});
it('should return true for content with http URL', () => {
expect(contentHasUrl('Check out http://example.com')).toBe(true);
});
it('should return true for real-world content', () => {
const content = `Punkte: 80500
Um alle Vorteile der Kundenkarte nutzen zu können...
https://h-k.me/QOHNTFVA`;
expect(contentHasUrl(content)).toBe(true);
});
});

View File

@@ -1,4 +1,7 @@
import { DialogSettings, KeyValueDTOOfStringAndString } from '@generated/swagger/crm-api';
import {
DialogSettings,
KeyValueDTOOfStringAndString,
} from '@generated/swagger/crm-api';
export interface DialogModel<T = any> {
actions?: Array<KeyValueDTOOfStringAndString>;
@@ -14,4 +17,21 @@ export interface DialogModel<T = any> {
* default: true
*/
handleCommand?: boolean;
/**
* If true, URLs in the content will be displayed as QR codes.
* default: false
*/
showUrlAsQrCode?: boolean;
}
/**
* Result of parsing content for URLs
*/
export interface ParsedDialogContent {
/** Text before the URL */
textBefore: string;
/** The extracted URL (if any) */
url: string | null;
/** Text after the URL */
textAfter: string;
}

View File

@@ -13,6 +13,7 @@ import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { DialogModel } from './dialog.model';
import { ToasterService } from '@shared/shell';
import { contentHasUrl } from './dialog.helper';
@Injectable()
export class OpenDialogInterceptor implements HttpInterceptor {
@@ -21,7 +22,10 @@ export class OpenDialogInterceptor implements HttpInterceptor {
private _toast: ToasterService,
) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
intercept(
req: HttpRequest<any>,
next: HttpHandler,
): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
tap((response) => {
if (response instanceof HttpResponse) {
@@ -59,9 +63,17 @@ export class OpenDialogInterceptor implements HttpInterceptor {
}
openDialog(model: DialogModel<any>) {
// Auto-detect URLs and enable QR code display if URL is found
// Can be overridden by explicitly setting showUrlAsQrCode in the model
const showUrlAsQrCode =
model.showUrlAsQrCode ?? contentHasUrl(model.content);
this._modal.open({
content: UiDialogModalComponent,
data: model,
data: {
...model,
showUrlAsQrCode,
},
title: model.title,
config: {
canClose: (model.settings & 1) === 1,

View File

@@ -2,7 +2,6 @@ import { OverlayModule } from '@angular/cdk/overlay';
import { CommonModule } from '@angular/common';
import { ModuleWithProviders, NgModule } from '@angular/core';
import { UiModalComponent } from './modal.component';
import { UiModalService } from './modal.service';
import { UiDebugModalComponent } from './debug-modal/debug-modal.component';
import { UiMessageModalComponent } from './message-modal.component';
import { UiIconModule } from '@ui/icon';
@@ -10,9 +9,10 @@ import { UiDialogModalComponent } from './dialog/dialog-modal.component';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { OpenDialogInterceptor } from './dialog/open-dialog.interceptor';
import { UiPromptModalComponent } from './prompt-modal';
import { QRCodeComponent } from 'angularx-qrcode';
@NgModule({
imports: [CommonModule, OverlayModule, UiIconModule],
imports: [CommonModule, OverlayModule, UiIconModule, QRCodeComponent],
declarations: [
UiModalComponent,
UiDebugModalComponent,

View File

@@ -1,5 +1,6 @@
/* tslint:disable */
import { EntityDTOBaseOfDisplayOrderItemDTOAndIOrderItem } from './entity-dtobase-of-display-order-item-dtoand-iorder-item';
import { KeyValueDTOOfStringAndString } from './key-value-dtoof-string-and-string';
import { LoyaltyDTO } from './loyalty-dto';
import { DisplayOrderDTO } from './display-order-dto';
import { PriceDTO } from './price-dto';
@@ -9,6 +10,11 @@ import { QuantityUnitType } from './quantity-unit-type';
import { DisplayOrderItemSubsetDTO } from './display-order-item-subset-dto';
export interface DisplayOrderItemDTO extends EntityDTOBaseOfDisplayOrderItemDTOAndIOrderItem{
/**
* Mögliche Aktionen
*/
actions?: Array<KeyValueDTOOfStringAndString>;
/**
* Bemerkung des Auftraggebers
*/

View File

@@ -1,10 +1,16 @@
/* tslint:disable */
import { EntityDTOBaseOfDisplayOrderItemSubsetDTOAndIOrderItemStatus } from './entity-dtobase-of-display-order-item-subset-dtoand-iorder-item-status';
import { KeyValueDTOOfStringAndString } from './key-value-dtoof-string-and-string';
import { DateRangeDTO } from './date-range-dto';
import { DisplayOrderItemDTO } from './display-order-item-dto';
import { OrderItemProcessingStatusValue } from './order-item-processing-status-value';
export interface DisplayOrderItemSubsetDTO extends EntityDTOBaseOfDisplayOrderItemSubsetDTOAndIOrderItemStatus{
/**
* Mögliche Aktionen
*/
actions?: Array<KeyValueDTOOfStringAndString>;
/**
* Abholfachnummer
*/
@@ -40,6 +46,11 @@ export interface DisplayOrderItemSubsetDTO extends EntityDTOBaseOfDisplayOrderIt
*/
estimatedShippingDate?: string;
/**
* Zusätzliche Markierungen (z.B. Abo, ...)
*/
features?: {[key: string]: string};
/**
* Bestellposten
*/

View File

@@ -118,6 +118,11 @@ export interface ReceiptDTO extends EntityDTOBaseOfReceiptDTOAndIReceipt{
*/
receiptNumber?: string;
/**
* Subtype of the receipt / Beleg-Unterart
*/
receiptSubType?: string;
/**
* Belegtext
*/

View File

@@ -4,12 +4,13 @@ import {
} from '@isa/checkout/data-access';
/**
* Creates a unique key for an item based on EAN, destination, and orderItemType.
* Creates a unique key for an item based on EAN, targetBranchId, and orderItemType.
* Items are only considered identical if all three match.
*/
export const getItemKey = (item: ShoppingCartItem): string => {
const ean = item.product.ean ?? 'no-ean';
const destinationId = item.destination?.data?.id ?? 'no-destination';
const targetBranchId =
item.destination?.data?.targetBranch?.id ?? 'no-target-branch-id';
const orderType = getOrderTypeFeature(item.features) ?? 'no-orderType';
return `${ean}|${destinationId}|${orderType}`;
return `${ean}|${targetBranchId}|${orderType}`;
};

View File

@@ -11,14 +11,13 @@ import {
ShoppingCartFacade,
SelectedRewardShoppingCartResource,
} from '@isa/checkout/data-access';
import { injectTabId } from '@isa/core/tabs';
import { injectTabId, TabService } from '@isa/core/tabs';
import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons';
import { PurchaseOptionsModalService } from '@modal/purchase-options';
import { firstValueFrom } from 'rxjs';
import { Router } from '@angular/router';
import { getRouteToCustomer } from '../helpers';
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
import { NavigationStateService } from '@isa/core/navigation';
@Component({
selector: 'reward-action',
@@ -32,7 +31,7 @@ export class RewardActionComponent {
#store = inject(RewardCatalogStore);
#tabId = injectTabId();
#navigationState = inject(NavigationStateService);
#tabService = inject(TabService);
#purchasingOptionsModal = inject(PurchaseOptionsModalService);
#shoppingCartFacade = inject(ShoppingCartFacade);
#checkoutMetadataService = inject(CheckoutMetadataService);
@@ -122,12 +121,9 @@ export class RewardActionComponent {
const route = getRouteToCustomer(tabId);
// Preserve context: Store current reward page URL to return to after customer selection
await this.#navigationState.preserveContext(
{
returnUrl: this.#router.url,
},
'select-customer',
);
this.#tabService.patchTabMetadata(tabId, {
'select-customer': { returnUrl: this.#router.url },
});
await this.#router.navigate(route.path, {
queryParams: route.queryParams,

View File

@@ -73,9 +73,6 @@ export class RewardCatalogComponent {
#filterService = inject(FilterService);
displayStockFilterSwitch = computed(() => {
if (this.isCallCenter) {
return [];
}
const stockInput = this.#filterService
.inputs()
?.filter((input) => input.target === 'filter')

View File

@@ -1,9 +1,8 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectTabId } from '@isa/core/tabs';
import { injectTabId, TabService } from '@isa/core/tabs';
import { Router } from '@angular/router';
import { getRouteToCustomer } from '../../helpers';
import { NavigationStateService } from '@isa/core/navigation';
@Component({
selector: 'reward-start-card',
@@ -13,7 +12,7 @@ import { NavigationStateService } from '@isa/core/navigation';
imports: [ButtonComponent],
})
export class RewardStartCardComponent {
readonly #navigationState = inject(NavigationStateService);
readonly #tabService = inject(TabService);
readonly #router = inject(Router);
tabId = injectTabId();
@@ -22,19 +21,19 @@ export class RewardStartCardComponent {
* Called when "Kund*in auswählen" button is clicked.
* Preserves the current URL as returnUrl before navigating to customer search.
*/
async onSelectCustomer() {
onSelectCustomer() {
const customerRoute = getRouteToCustomer(this.tabId());
const tabId = this.#tabService.activatedTabId();
// Preserve context: Store current reward page URL to return to after customer selection
await this.#navigationState.preserveContext(
{
returnUrl: this.#router.url,
},
'select-customer',
);
if (tabId) {
this.#tabService.patchTabMetadata(tabId, {
'select-customer': { returnUrl: this.#router.url },
});
}
// Navigate to customer search
await this.#router.navigate(customerRoute.path, {
this.#router.navigate(customerRoute.path, {
queryParams: customerRoute.queryParams,
});
}

View File

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

View File

@@ -49,7 +49,7 @@ export class OrderConfirmationHeaderComponent {
if (!orders || orders.length === 0) {
return '';
}
return orders
const formattedDates = orders
.map((order) => {
if (!order.orderDate) {
return null;
@@ -60,7 +60,8 @@ export class OrderConfirmationHeaderComponent {
);
return formatted ? `${formatted} Uhr` : null;
})
.filter(Boolean)
.join('; ');
.filter(Boolean);
return [...new Set(formattedDates)].join('; ');
});
}

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

@@ -14,8 +14,8 @@ import { isaActionEdit } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { AddressComponent } from '@isa/shared/address';
import { injectTabId } from '@isa/core/tabs';
import { NavigationStateService } from '@isa/core/navigation';
import { injectTabId, TabService } from '@isa/core/tabs';
import { Router } from '@angular/router';
@Component({
selector: 'checkout-billing-and-shipping-address-card',
@@ -26,7 +26,8 @@ import { NavigationStateService } from '@isa/core/navigation';
providers: [provideIcons({ isaActionEdit })],
})
export class BillingAndShippingAddressCardComponent {
#navigationState = inject(NavigationStateService);
#tabService = inject(TabService);
#router = inject(Router);
#shippingAddressResource = inject(SelectedCustomerShippingAddressResource);
#payerAddressResource = inject(SelectedCustomerPayerAddressResource);
@@ -45,18 +46,19 @@ export class BillingAndShippingAddressCardComponent {
return this.#customerResource.value();
});
async navigateToCustomer() {
navigateToCustomer() {
const customerId = this.customer()?.id;
if (!customerId) return;
const tabId = this.tabId();
if (!customerId || !tabId) return;
const returnUrl = `/${this.tabId()}/reward/cart`;
const returnUrl = `/${tabId}/reward/cart`;
// Preserve context across intermediate navigations (auto-scoped to active tab)
await this.#navigationState.navigateWithPreservedContext(
['/', 'kunde', this.tabId(), 'customer', 'search', customerId],
{ returnUrl },
'select-customer',
);
// Preserve context across intermediate navigations (scoped to active tab)
this.#tabService.patchTabMetadata(tabId, {
'select-customer': { returnUrl },
});
this.#router.navigate(['/', 'kunde', tabId, 'customer', 'search', customerId]);
}
payer = computed(() => {

View File

@@ -66,7 +66,8 @@ export class RewardShoppingCartItemQuantityControlComponent {
if (
orderType === OrderTypeFeature.Delivery ||
orderType === OrderTypeFeature.DigitalShipping ||
orderType === OrderTypeFeature.B2BShipping
orderType === OrderTypeFeature.B2BShipping ||
orderType === OrderTypeFeature.Pickup
) {
return 999;
}

View File

@@ -37,11 +37,20 @@
</div>
</div>
@if (quantityControl.maxQuantity() < 2 && !isDownload()) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
</div>
@if (!isDownload()) {
@if (showLowStockMessage()) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>{{ inStock() }} Exemplare sofort lieferbar</div>
</div>
} @else if (quantityControl.maxQuantity() < 2) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
</div>
}
}

View File

@@ -72,9 +72,20 @@ export class RewardShoppingCartItemComponent {
hasOrderTypeFeature(this.item().features, ['Download']),
);
isAbholung = computed(() =>
hasOrderTypeFeature(this.item().features, ['Abholung']),
);
inStock = computed(() => this.item().availability?.inStock ?? 0);
showLowStockMessage = computed(() => {
return this.isAbholung() && this.inStock() < 2;
});
async updatePurchaseOption() {
const shoppingCartItemId = this.itemId();
const shoppingCartId = this.shoppingCartId();
const branch = this.item().destination?.data?.targetBranch?.data;
if (this.isBusy() || !shoppingCartId || !shoppingCartItemId) {
return;
@@ -90,6 +101,8 @@ export class RewardShoppingCartItemComponent {
useRedemptionPoints: true,
disabledPurchaseOptions: ['b2b-delivery'],
hideDisabledPurchaseOptions: true,
pickupBranch: branch,
inStoreBranch: branch,
});
await firstValueFrom(ref.afterClosed$);

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

@@ -1,3 +1,3 @@
:host {
@apply text-isa-accent-red isa-text-body-2-bold flex flex-row gap-2 items-center;
@apply text-isa-accent-red isa-text-body-2-bold flex flex-col gap-2 items-start;
}

View File

@@ -1,8 +1,10 @@
@if (store.totalLoyaltyPointsNeeded() > store.customerRewardPoints()) {
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>Lesepunkte reichen nicht für alle Artikel</span>
<div class="flex flex-row gap-2 items-center">
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>Lesepunkte reichen nicht für alle Artikel</span>
</div>
}

View File

@@ -41,10 +41,7 @@ export class RewardSelectionInputsComponent {
hasCorrectOrderType = computed(() => {
const item = this.rewardSelectionItem().item;
return hasOrderTypeFeature(item.features, [
OrderTypeFeature.InStore,
OrderTypeFeature.Pickup,
]);
return hasOrderTypeFeature(item.features, [OrderTypeFeature.InStore]);
});
hasStock = computed(() => {

View File

@@ -27,3 +27,16 @@
<lib-reward-selection-inputs></lib-reward-selection-inputs>
</div>
@if (showLowStockMessage()) {
<div
class="flex flex-row gap-2 items-center text-isa-accent-red isa-text-body-2-bold"
>
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>{{ inStock() }} Exemplare sofort lieferbar</span>
</div>
}

View File

@@ -1,8 +1,18 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { RewardSelectionInputsComponent } from './reward-selection-inputs/reward-selection-inputs.component';
import { RewardSelectionItem } from '@isa/checkout/data-access';
import {
hasOrderTypeFeature,
RewardSelectionItem,
} from '@isa/checkout/data-access';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaOtherInfo } from '@isa/icons';
@Component({
selector: 'lib-reward-selection-item',
@@ -13,8 +23,24 @@ import { RewardSelectionItem } from '@isa/checkout/data-access';
ProductImageDirective,
ProductRouterLinkDirective,
RewardSelectionInputsComponent,
NgIcon,
],
providers: [provideIcons({ isaOtherInfo })],
})
export class RewardSelectionItemComponent {
rewardSelectionItem = input.required<RewardSelectionItem>();
inStock = computed(
() => this.rewardSelectionItem().item?.availability?.inStock ?? 0,
);
isAbholung = computed(() =>
hasOrderTypeFeature(this.rewardSelectionItem()?.item?.features, [
'Abholung',
]),
);
showLowStockMessage = computed(() => {
return this.isAbholung() && this.inStock() < 2;
});
}

View File

@@ -34,8 +34,15 @@ export const catchResponseArgsErrorPipe = <T>(): OperatorFunction<T, T> =>
return throwError(() => err);
}),
mergeMap((response) => {
if (isResponseArgs(response) && response.error === true) {
return throwError(() => new ResponseArgsError(response));
if (isResponseArgs(response)) {
// Treat as error if error flag is true OR if invalidProperties has entries
const hasInvalidProps =
response.invalidProperties &&
Object.keys(response.invalidProperties).length > 0;
if (response.error === true || hasInvalidProps) {
return throwError(() => new ResponseArgsError(response));
}
}
return [response];

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

@@ -12,7 +12,7 @@ module.exports = [
'error',
{
type: 'attribute',
prefix: 'core',
prefix: 'common',
style: 'camelCase',
},
],
@@ -20,7 +20,7 @@ module.exports = [
'error',
{
type: 'element',
prefix: 'core',
prefix: 'common',
style: 'kebab-case',
},
],

View File

@@ -1,20 +1,20 @@
{
"name": "core-navigation",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/core/navigation/src",
"prefix": "core",
"projectType": "library",
"tags": ["skip:ci"],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/core/navigation"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
{
"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

@@ -8,7 +8,7 @@ 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/core/navigation',
cacheDir: '../../../node_modules/.vite/libs/common/title-management',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
@@ -22,10 +22,10 @@ defineConfig(() => ({
setupFiles: ['src/test-setup.ts'],
reporters: [
'default',
['junit', { outputFile: '../../../testresults/junit-core-navigation.xml' }],
['junit', { outputFile: '../../../testresults/junit-common-title-management.xml' }],
],
coverage: {
reportsDirectory: '../../../coverage/libs/core/navigation',
reportsDirectory: '../../../coverage/libs/common/title-management',
provider: 'v8' as const,
reporter: ['text', 'cobertura'],
},

View File

@@ -1,855 +0,0 @@
# @isa/core/navigation
A reusable Angular library providing **context preservation** for multi-step navigation flows with automatic tab-scoped storage.
## Overview
`@isa/core/navigation` solves the problem of **lost navigation state** during intermediate navigations. Unlike Angular's router state which is lost after intermediate navigations, this library persists navigation context in **tab metadata** with automatic cleanup when tabs close.
### The Problem It Solves
```typescript
// ❌ Problem: Router state is lost during intermediate navigations
await router.navigate(['/customer/search'], {
state: { returnUrl: '/reward/cart' } // Works for immediate navigation
});
// After intermediate navigations:
// /customer/search → /customer/details → /add-shipping-address
// ⚠️ The returnUrl is LOST!
```
### The Solution
```typescript
// ✅ Solution: Context preservation survives intermediate navigations
navState.preserveContext({ returnUrl: '/reward/cart' });
// Context persists in tab metadata, automatically cleaned up when tab closes
// After multiple intermediate navigations:
const context = navState.restoreAndClearContext();
// ✅ returnUrl is PRESERVED!
```
## Features
-**Survives Intermediate Navigations** - State persists across multiple navigation steps
-**Automatic Tab Scoping** - Contexts automatically isolated per tab using `TabService`
-**Automatic Cleanup** - Contexts cleared automatically when tabs close (no manual cleanup needed)
-**Hierarchical Scoping** - Combine tab ID with custom scopes (e.g., `"customer-details"`)
-**Type-Safe** - Full TypeScript generics support
-**Simple API** - No context IDs to track, scope is the identifier
-**Auto-Restore on Refresh** - Contexts survive page refresh (via TabService UserStorage persistence)
-**Map-Based Storage** - One context per scope for clarity
-**Platform-Agnostic** - Works with Angular Universal (SSR)
-**Zero URL Pollution** - No query parameters needed
## Installation
This library is part of the ISA Frontend monorepo. Import from the path alias:
```typescript
import { NavigationStateService } from '@isa/core/navigation';
```
## Quick Start
### Basic Flow
```typescript
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { NavigationStateService } from '@isa/core/navigation';
@Component({
selector: 'app-cart',
template: `<button (click)="editCustomer()">Edit Customer</button>`
})
export class CartComponent {
private router = inject(Router);
private navState = inject(NavigationStateService);
async editCustomer() {
// Start flow - preserve context (auto-scoped to active tab)
this.navState.preserveContext({
returnUrl: '/reward/cart',
customerId: 123
});
await this.router.navigate(['/customer/search']);
}
}
@Component({
selector: 'app-customer-details',
template: `<button (click)="complete()">Complete</button>`
})
export class CustomerDetailsComponent {
private router = inject(Router);
private navState = inject(NavigationStateService);
async complete() {
// End flow - restore and auto-cleanup (auto-scoped to active tab)
const context = this.navState.restoreAndClearContext<{ returnUrl: string }>();
if (context?.returnUrl) {
await this.router.navigateByUrl(context.returnUrl);
}
}
}
```
### Simplified Navigation
Use `navigateWithPreservedContext()` to combine navigation + context preservation:
```typescript
async editCustomer() {
// Navigate and preserve in one call
const { success } = await this.navState.navigateWithPreservedContext(
['/customer/search'],
{ returnUrl: '/reward/cart', customerId: 123 }
);
}
```
## Core API
### Context Management
#### `preserveContext<T>(state, customScope?)`
Save navigation context that survives intermediate navigations.
```typescript
// Default tab scope
navState.preserveContext({
returnUrl: '/reward/cart',
selectedItems: [1, 2, 3]
});
// Custom scope within tab
navState.preserveContext(
{ customerId: 42 },
'customer-details' // Stored as 'customer-details' in active tab's metadata
);
```
**Parameters:**
- `state`: The data to preserve (any object)
- `customScope` (optional): Custom scope within the tab (e.g., `'customer-details'`)
**Storage Location:**
- Stored in active tab's metadata at: `tab.metadata['navigation-contexts'][scopeKey]`
- Default scope: `'default'`
- Custom scope: `customScope` value
---
#### `patchContext<T>(partialState, customScope?)`
Partially update preserved context without replacing the entire context.
This method merges partial state with the existing context, preserving properties you don't specify. Properties explicitly set to `undefined` will be removed from the context.
```typescript
// Existing context: { returnUrl: '/cart', autoTriggerContinueFn: true, customerId: 123 }
// Clear one property while preserving others
await navState.patchContext(
{ autoTriggerContinueFn: undefined },
'select-customer'
);
// Result: { returnUrl: '/cart', customerId: 123 }
// Update one property while preserving others
await navState.patchContext(
{ customerId: 456 },
'select-customer'
);
// Result: { returnUrl: '/cart', customerId: 456 }
// Add new property to existing context
await navState.patchContext(
{ selectedTab: 'details' },
'select-customer'
);
// Result: { returnUrl: '/cart', customerId: 456, selectedTab: 'details' }
```
**Parameters:**
- `partialState`: Partial state object to merge (set properties to `undefined` to remove them)
- `customScope` (optional): Custom scope within the tab
**Use Cases:**
- Clear trigger flags while preserving flow data
- Update specific properties without affecting others
- Remove properties from context
- Add properties to existing context
**Comparison with `preserveContext`:**
- `preserveContext`: Replaces entire context (overwrites all properties)
- `patchContext`: Merges with existing context (updates only specified properties)
```typescript
// ❌ preserveContext - must manually preserve existing properties
const context = await navState.restoreContext();
await navState.preserveContext({
returnUrl: context?.returnUrl, // Must specify
customerId: context?.customerId, // Must specify
autoTriggerContinueFn: undefined, // Clear this
});
// ✅ patchContext - automatically preserves unspecified properties
await navState.patchContext({
autoTriggerContinueFn: undefined, // Only specify what changes
});
```
---
#### `restoreContext<T>(customScope?)`
Retrieve preserved context **without** removing it.
```typescript
// Default tab scope
const context = navState.restoreContext<{ returnUrl: string }>();
if (context?.returnUrl) {
console.log('Return URL:', context.returnUrl);
}
// Custom scope
const context = navState.restoreContext<{ customerId: number }>('customer-details');
```
**Parameters:**
- `customScope` (optional): Custom scope to retrieve from (defaults to 'default')
**Returns:** The preserved data, or `null` if not found
---
#### `restoreAndClearContext<T>(customScope?)`
Retrieve preserved context **and automatically remove** it (recommended for cleanup).
```typescript
// Default tab scope
const context = navState.restoreAndClearContext<{ returnUrl: string }>();
if (context?.returnUrl) {
await router.navigateByUrl(context.returnUrl);
}
// Custom scope
const context = navState.restoreAndClearContext<{ customerId: number }>('customer-details');
```
**Parameters:**
- `customScope` (optional): Custom scope to retrieve from (defaults to 'default')
**Returns:** The preserved data, or `null` if not found
---
#### `clearPreservedContext(customScope?)`
Manually remove a context without retrieving its data.
```typescript
// Clear default tab scope
navState.clearPreservedContext();
// Clear custom scope
navState.clearPreservedContext('customer-details');
```
---
#### `hasPreservedContext(customScope?)`
Check if a context exists.
```typescript
// Check default tab scope
if (navState.hasPreservedContext()) {
const context = navState.restoreContext();
}
// Check custom scope
if (navState.hasPreservedContext('customer-details')) {
const context = navState.restoreContext('customer-details');
}
```
---
### Navigation Helpers
#### `navigateWithPreservedContext(commands, state, customScope?, extras?)`
Navigate and preserve context in one call.
```typescript
const { success } = await navState.navigateWithPreservedContext(
['/customer/search'],
{ returnUrl: '/reward/cart' },
'customer-flow', // optional customScope
{ queryParams: { foo: 'bar' } } // optional NavigationExtras
);
// Later...
const context = navState.restoreAndClearContext('customer-flow');
```
---
### Cleanup Methods
#### `clearScopeContexts()`
Clear all contexts for the active tab (both default and custom scopes).
```typescript
// Clear all contexts for active tab
const cleared = this.navState.clearScopeContexts();
console.log(`Cleaned up ${cleared} contexts`);
```
**Returns:** Number of contexts cleared
**Note:** This is typically not needed because contexts are **automatically cleaned up when the tab closes**. Use this only for explicit cleanup during the tab's lifecycle.
---
## Usage Patterns
### Pattern 1: Multi-Step Flow with Intermediate Navigations
**Problem:** You need to return to a page after multiple intermediate navigations.
```typescript
// Component A: Start of flow
export class RewardCartComponent {
navState = inject(NavigationStateService);
router = inject(Router);
async selectCustomer() {
// Preserve returnUrl (auto-scoped to tab)
this.navState.preserveContext({
returnUrl: '/reward/cart'
});
await this.router.navigate(['/customer/search']);
}
}
// Component B: Intermediate navigation
export class CustomerSearchComponent {
router = inject(Router);
async viewDetails(customerId: number) {
await this.router.navigate(['/customer/details', customerId]);
// Context still persists!
}
}
// Component C: Another intermediate navigation
export class CustomerDetailsComponent {
router = inject(Router);
async addShippingAddress() {
await this.router.navigate(['/add-shipping-address']);
// Context still persists!
}
}
// Component D: End of flow
export class FinalStepComponent {
navState = inject(NavigationStateService);
router = inject(Router);
async complete() {
// Restore context (auto-scoped to tab) and navigate back
const context = this.navState.restoreAndClearContext<{ returnUrl: string }>();
if (context?.returnUrl) {
await this.router.navigateByUrl(context.returnUrl);
}
}
}
```
---
### Pattern 2: Multiple Flows in Same Tab
Use custom scopes to manage different flows within the same tab.
```typescript
export class ComplexPageComponent {
navState = inject(NavigationStateService);
async startCustomerFlow() {
// Store context for customer flow
this.navState.preserveContext(
{ returnUrl: '/dashboard', step: 1 },
'customer-flow'
);
// Stored in active tab metadata under scope 'customer-flow'
}
async startProductFlow() {
// Store context for product flow
this.navState.preserveContext(
{ returnUrl: '/dashboard', selectedProducts: [1, 2] },
'product-flow'
);
// Stored in active tab metadata under scope 'product-flow'
}
async completeCustomerFlow() {
// Restore from customer flow
const context = this.navState.restoreAndClearContext('customer-flow');
}
async completeProductFlow() {
// Restore from product flow
const context = this.navState.restoreAndClearContext('product-flow');
}
}
```
---
### Pattern 3: Complex Context Data
```typescript
interface CheckoutContext {
returnUrl: string;
selectedItems: number[];
customerId: number;
shippingAddressId?: number;
metadata: {
source: 'reward' | 'checkout';
timestamp: number;
};
}
// Save
navState.preserveContext<CheckoutContext>({
returnUrl: '/reward/cart',
selectedItems: [1, 2, 3],
customerId: 456,
metadata: {
source: 'reward',
timestamp: Date.now()
}
});
// Restore with type safety
const context = navState.restoreAndClearContext<CheckoutContext>();
if (context) {
console.log('Items:', context.selectedItems);
console.log('Customer:', context.customerId);
}
```
---
### Pattern 4: No Manual Cleanup Needed
```typescript
export class TabAwareComponent {
navState = inject(NavigationStateService);
async startFlow() {
// Set context
this.navState.preserveContext({ returnUrl: '/home' });
// No need to clear in ngOnDestroy!
// Context is automatically cleaned up when tab closes
}
// ❌ NOT NEEDED:
// ngOnDestroy() {
// this.navState.clearScopeContexts();
// }
}
```
---
## Architecture
### How It Works
```mermaid
graph LR
A[NavigationStateService] --> B[NavigationContextService]
B --> C[TabService]
C --> D[Tab Metadata Storage]
D --> E[UserStorage Persistence]
style A fill:#e1f5ff
style B fill:#e1f5ff
style C fill:#fff4e1
style D fill:#e8f5e9
style E fill:#f3e5f5
```
1. **Context Storage**: Contexts are stored in **tab metadata** using `TabService`
2. **Automatic Scoping**: Active tab ID determines storage location automatically
3. **Hierarchical Keys**: Scopes are organized as `tab.metadata['navigation-contexts'][customScope]`
4. **Automatic Cleanup**: Contexts removed automatically when tabs close (via tab lifecycle)
5. **Persistent Across Refresh**: Tab metadata persists via UserStorage, so contexts survive page refresh
6. **Map-Based**: One context per scope for clarity
### Tab Metadata Structure
```typescript
// Example: Tab with ID 123
tab.metadata = {
'navigation-contexts': {
'default': {
data: { returnUrl: '/cart', selectedItems: [1, 2, 3] },
createdAt: 1234567890000
},
'customer-details': {
data: { customerId: 42, step: 2 },
createdAt: 1234567891000
},
'product-flow': {
data: { productIds: [100, 200], source: 'recommendation' },
createdAt: 1234567892000
}
},
// ... other tab metadata
}
```
### Storage Layers
```
┌─────────────────────────────────────┐
│ NavigationStateService (Public API)│
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ NavigationContextService (Storage) │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ TabService.patchTabMetadata() │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ Tab Metadata Storage (In-Memory) │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ UserStorage (SessionStorage) │
│ (Automatic Persistence) │
└─────────────────────────────────────┘
```
### Integration with TabService
This library **requires** `@isa/core/tabs` for automatic tab scoping:
```typescript
import { TabService } from '@isa/core/tabs';
// NavigationContextService uses:
const tabId = this.tabService.activatedTabId(); // Returns: number | null
if (tabId !== null) {
// Store in: tab.metadata['navigation-contexts'][customScope]
this.tabService.patchTabMetadata(tabId, {
'navigation-contexts': {
[customScope]: { data, createdAt }
}
});
}
```
**When no tab is active** (tabId = null):
- Operations throw an error to prevent data loss
- This ensures contexts are always properly scoped to a tab
---
## Migration Guide
### From SessionStorage to Tab Metadata
This library previously used SessionStorage for context persistence. It has been refactored to use tab metadata for better integration with the tab lifecycle and automatic cleanup.
### What Changed
**Storage Location:**
- **Before**: SessionStorage with key `'isa:navigation:context-map'`
- **After**: Tab metadata at `tab.metadata['navigation-contexts']`
**Cleanup:**
- **Before**: Manual cleanup required + automatic expiration after 24 hours
- **After**: Automatic cleanup when tab closes (no manual cleanup needed)
**Scope Keys:**
- **Before**: `"123"` (tab ID), `"123-customer-details"` (tab ID + custom scope)
- **After**: `"default"`, `"customer-details"` (custom scope only, tab ID implicit from storage location)
**TTL Parameter:**
- **Before**: `preserveContext(data, customScope, ttl)` - TTL respected
- **After**: `preserveContext(data, customScope, ttl)` - TTL parameter ignored (kept for compatibility)
### What Stayed the Same
**Public API**: All public methods remain unchanged
**Type Safety**: Full TypeScript support with generics
**Hierarchical Scoping**: Custom scopes still work the same way
**Usage Patterns**: All existing code continues to work
**Persistence**: Contexts still survive page refresh (via TabService UserStorage)
### Benefits of Tab Metadata Approach
1. **Automatic Cleanup**: No need to manually clear contexts or worry about stale data
2. **Better Integration**: Seamless integration with tab lifecycle management
3. **Simpler Mental Model**: Contexts are "owned" by tabs, not global storage
4. **No TTL Management**: Tab lifecycle handles cleanup automatically
5. **Safer**: Impossible to leak contexts across unrelated tabs
### Migration Steps
**No action required!** The public API is unchanged. Your existing code will continue to work:
```typescript
// ✅ This code works exactly the same before and after migration
navState.preserveContext({ returnUrl: '/cart' });
const context = navState.restoreAndClearContext<{ returnUrl: string }>();
```
**Optional: Remove manual cleanup code**
If you have manual cleanup in `ngOnDestroy`, you can safely remove it:
```typescript
// Before (still works, but unnecessary):
ngOnDestroy() {
this.navState.clearScopeContexts();
}
// After (automatic cleanup):
ngOnDestroy() {
// No cleanup needed - tab lifecycle handles it!
}
```
**Note on TTL parameter**
If you were using the TTL parameter, be aware it's now ignored:
```typescript
// Before: TTL respected
navState.preserveContext({ data: 'foo' }, undefined, 60000); // Expires in 1 minute
// After: TTL ignored (context lives until tab closes)
navState.preserveContext({ data: 'foo' }, undefined, 60000); // Ignored parameter
```
---
## Testing
### Mocking NavigationStateService
```typescript
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NavigationStateService } from '@isa/core/navigation';
import { describe, it, expect, beforeEach, vi } from 'vitest';
describe('MyComponent', () => {
let component: MyComponent;
let fixture: ComponentFixture<MyComponent>;
let navStateMock: any;
beforeEach(async () => {
navStateMock = {
preserveContext: vi.fn(),
restoreContext: vi.fn().mockReturnValue({ returnUrl: '/test' }),
restoreAndClearContext: vi.fn().mockReturnValue({ returnUrl: '/test' }),
clearPreservedContext: vi.fn().mockReturnValue(true),
hasPreservedContext: vi.fn().mockReturnValue(true),
};
await TestBed.configureTestingModule({
imports: [MyComponent],
providers: [
{ provide: NavigationStateService, useValue: navStateMock }
]
}).compileComponents();
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
});
it('should preserve context when navigating', async () => {
await component.startFlow();
expect(navStateMock.preserveContext).toHaveBeenCalledWith({
returnUrl: '/reward/cart'
});
});
it('should restore context and navigate back', async () => {
navStateMock.restoreAndClearContext.mockReturnValue({ returnUrl: '/cart' });
await component.complete();
expect(navStateMock.restoreAndClearContext).toHaveBeenCalled();
// Assert navigation occurred
});
});
```
---
## Best Practices
### ✅ Do
- **Use `restoreAndClearContext()`** for automatic cleanup when completing flows
- **Use custom scopes** for multiple concurrent flows in the same tab
- **Leverage type safety** with TypeScript generics (`<T>`)
- **Trust automatic cleanup** - no need to manually clear contexts when tabs close
- **Check for null** when restoring contexts (they may not exist)
### ❌ Don't
- **Don't store large objects** - keep contexts lean (return URLs, IDs, simple flags)
- **Don't use for persistent data** - use NgRx or services for long-lived state
- **Don't rely on TTL** - the TTL parameter is ignored in the current implementation
- **Don't manually clear in ngOnDestroy** - tab lifecycle handles it automatically
- **Don't store sensitive data** - contexts may be visible in browser dev tools
### When to Use Navigation Context
**Good Use Cases:**
- Return URLs for multi-step flows
- Wizard/multi-step form state
- Temporary search filters or selections
- Flow-specific context (customer ID during checkout)
**Bad Use Cases:**
- User preferences (use NgRx or services)
- Authentication tokens (use dedicated auth service)
- Large datasets (use data services with caching)
- Cross-tab communication (use BroadcastChannel or shared services)
---
## Configuration
### Constants
All configuration is in `navigation-context.constants.ts`:
```typescript
// Metadata key for storing contexts in tab metadata
export const NAVIGATION_CONTEXT_METADATA_KEY = 'navigation-contexts';
```
**Note:** Previous SessionStorage constants (`DEFAULT_CONTEXT_TTL`, `CLEANUP_INTERVAL`, `NAVIGATION_CONTEXT_STORAGE_KEY`) have been removed as they are no longer needed with tab metadata storage.
---
## API Reference Summary
| Method | Parameters | Returns | Purpose |
|--------|-----------|---------|---------|
| `preserveContext(state, customScope?)` | state: T, customScope?: string | void | Save context |
| `patchContext(partialState, customScope?)` | partialState: Partial<T>, customScope?: string | void | Merge partial updates |
| `restoreContext(customScope?)` | customScope?: string | T \| null | Get context (keep) |
| `restoreAndClearContext(customScope?)` | customScope?: string | T \| null | Get + remove |
| `clearPreservedContext(customScope?)` | customScope?: string | boolean | Remove context |
| `hasPreservedContext(customScope?)` | customScope?: string | boolean | Check exists |
| `navigateWithPreservedContext(...)` | commands, state, customScope?, extras? | Promise<{success}> | Navigate + preserve |
| `clearScopeContexts()` | none | number | Bulk cleanup (rarely needed) |
---
## Troubleshooting
### Context Not Found After Refresh
**Problem**: Context is `null` after page refresh.
**Solution**: Ensure `TabService` is properly initialized and the tab ID is restored from UserStorage. Contexts rely on tab metadata which persists via UserStorage.
### Context Cleared Unexpectedly
**Problem**: Context disappears before you retrieve it.
**Solution**: Check if you're using `restoreAndClearContext()` multiple times. This method removes the context after retrieval. Use `restoreContext()` if you need to access it multiple times.
### "No active tab" Error
**Problem**: Getting error "No active tab - cannot set navigation context".
**Solution**: Ensure `TabService` has an active tab before using navigation context. This typically happens during app initialization before tabs are ready.
### Context Not Isolated Between Tabs
**Problem**: Contexts from one tab appearing in another.
**Solution**: This should not happen with tab metadata storage. If you see this, it may indicate a TabService issue. Check that `TabService.activatedTabId()` returns the correct tab ID.
---
## Running Tests
```bash
# Run tests
npx nx test core-navigation
# Run tests with coverage
npx nx test core-navigation --coverage.enabled=true
# Run tests without cache (CI)
npx nx test core-navigation --skip-cache
```
**Test Results:**
- 79 tests passing
- 2 test files (navigation-state.service.spec.ts, navigation-context.service.spec.ts)
---
## CI/CD Integration
This library generates JUnit and Cobertura reports for Azure Pipelines:
- **JUnit Report**: `testresults/junit-core-navigation.xml`
- **Cobertura Report**: `coverage/libs/core/navigation/cobertura-coverage.xml`
---
## Contributing
This library follows the ISA Frontend monorepo conventions:
- **Path Alias**: `@isa/core/navigation`
- **Testing Framework**: Vitest with Angular Testing Utilities
- **Code Style**: ESLint + Prettier
- **Test Coverage**: Required for all public APIs
- **Dependencies**: Requires `@isa/core/tabs` for tab scoping
---
## License
Internal ISA Frontend monorepo library.

View File

@@ -1,5 +0,0 @@
export * from './lib/navigation-state.types';
export * from './lib/navigation-state.service';
export * from './lib/navigation-context.types';
export * from './lib/navigation-context.service';
export * from './lib/navigation-context.constants';

View File

@@ -1,22 +0,0 @@
/**
* Constants for navigation context storage in tab metadata.
* Navigation contexts are stored directly in tab metadata instead of sessionStorage,
* providing automatic cleanup when tabs are closed and better integration with the tab system.
*/
/**
* Key used to store navigation contexts in tab metadata.
* Contexts are stored as: tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY][customScope]
*
* @example
* ```typescript
* // Structure in tab metadata:
* tab.metadata = {
* 'navigation-contexts': {
* 'default': { data: { returnUrl: '/cart' }, createdAt: 123 },
* 'customer-details': { data: { customerId: 42 }, createdAt: 456 }
* }
* }
* ```
*/
export const NAVIGATION_CONTEXT_METADATA_KEY = 'navigation-contexts';

View File

@@ -1,668 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { signal } from '@angular/core';
import { NavigationContextService } from './navigation-context.service';
import { TabService } from '@isa/core/tabs';
import { ReturnUrlContext } from './navigation-context.types';
import { NAVIGATION_CONTEXT_METADATA_KEY } from './navigation-context.constants';
describe('NavigationContextService', () => {
let service: NavigationContextService;
let tabServiceMock: {
activatedTabId: ReturnType<typeof signal<number | null>>;
entityMap: ReturnType<typeof vi.fn>;
patchTabMetadata: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Create mock TabService with signals and methods
tabServiceMock = {
activatedTabId: signal<number | null>(null),
entityMap: vi.fn(),
patchTabMetadata: vi.fn(),
};
TestBed.configureTestingModule({
providers: [
NavigationContextService,
{ provide: TabService, useValue: tabServiceMock },
],
});
service = TestBed.inject(NavigationContextService);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('setContext', () => {
it('should set context in tab metadata', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
const data: ReturnUrlContext = { returnUrl: '/test-page' };
// Act
await service.setContext(data);
// Assert
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
tabId,
expect.objectContaining({
[NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
default: expect.objectContaining({
data,
createdAt: expect.any(Number),
}),
}),
}),
);
});
it('should set context with custom scope', async () => {
// Arrange
const tabId = 123;
const customScope = 'customer-details';
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
const data = { customerId: 42 };
// Act
await service.setContext(data, customScope);
// Assert
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
tabId,
expect.objectContaining({
[NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
[customScope]: expect.objectContaining({
data,
createdAt: expect.any(Number),
}),
}),
}),
);
});
it('should throw error when no active tab', async () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
// Act & Assert
await expect(service.setContext({ returnUrl: '/test' })).rejects.toThrow(
'No active tab - cannot set navigation context',
);
});
it('should merge with existing contexts', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
const existingContexts = {
'existing-scope': {
data: { existingData: 'value' },
createdAt: Date.now() - 1000,
},
};
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: existingContexts,
},
},
});
const newData = { returnUrl: '/new-page' };
// Act
await service.setContext(newData);
// Assert
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
tabId,
expect.objectContaining({
[NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
'existing-scope': existingContexts['existing-scope'],
default: expect.objectContaining({
data: newData,
}),
}),
}),
);
});
it('should accept TTL parameter for backward compatibility', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
await service.setContext({ returnUrl: '/test' }, undefined, 60000);
// Assert - TTL is ignored but method should still work
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalled();
});
});
describe('getContext', () => {
it('should return null when no active tab', async () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
// Act
const result = await service.getContext();
// Assert
expect(result).toBeNull();
});
it('should return null when context does not exist', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const result = await service.getContext();
// Assert
expect(result).toBeNull();
});
it('should retrieve context from default scope', async () => {
// Arrange
const tabId = 123;
const data: ReturnUrlContext = { returnUrl: '/test-page' };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
default: {
data,
createdAt: Date.now(),
},
},
},
},
});
// Act
const result = await service.getContext<ReturnUrlContext>();
// Assert
expect(result).toEqual(data);
});
it('should retrieve context from custom scope', async () => {
// Arrange
const tabId = 123;
const customScope = 'checkout-flow';
const data = { step: 2, productId: 456 };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
[customScope]: {
data,
createdAt: Date.now(),
},
},
},
},
});
// Act
const result = await service.getContext(customScope);
// Assert
expect(result).toEqual(data);
});
it('should return null when tab not found', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({});
// Act
const result = await service.getContext();
// Assert
expect(result).toBeNull();
});
it('should handle invalid metadata gracefully', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: 'invalid', // Invalid type
},
},
});
// Act
const result = await service.getContext();
// Assert
expect(result).toBeNull();
});
});
describe('getAndClearContext', () => {
it('should return null when no active tab', async () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
// Act
const result = await service.getAndClearContext();
// Assert
expect(result).toBeNull();
});
it('should retrieve and remove context from default scope', async () => {
// Arrange
const tabId = 123;
const data: ReturnUrlContext = { returnUrl: '/test-page' };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
default: {
data,
createdAt: Date.now(),
},
},
},
},
});
// Act
const result = await service.getAndClearContext<ReturnUrlContext>();
// Assert
expect(result).toEqual(data);
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
});
});
it('should retrieve and remove context from custom scope', async () => {
// Arrange
const tabId = 123;
const customScope = 'wizard-flow';
const data = { currentStep: 3 };
const otherScopeData = { otherData: 'value' };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
[customScope]: {
data,
createdAt: Date.now(),
},
'other-scope': {
data: otherScopeData,
createdAt: Date.now(),
},
},
},
},
});
// Act
const result = await service.getAndClearContext(customScope);
// Assert
expect(result).toEqual(data);
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
'other-scope': expect.objectContaining({
data: otherScopeData,
}),
},
});
});
it('should return null when context not found', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const result = await service.getAndClearContext();
// Assert
expect(result).toBeNull();
expect(tabServiceMock.patchTabMetadata).not.toHaveBeenCalled();
});
});
describe('clearContext', () => {
it('should return true when context exists and is cleared', async () => {
// Arrange
const tabId = 123;
const data = { returnUrl: '/test' };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
default: { data, createdAt: Date.now() },
},
},
},
});
// Act
const result = await service.clearContext();
// Assert
expect(result).toBe(true);
});
it('should return false when context not found', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const result = await service.clearContext();
// Assert
expect(result).toBe(false);
});
});
describe('clearScope', () => {
it('should clear all contexts for active tab', async () => {
// Arrange
const tabId = 123;
const contexts = {
default: { data: { url: '/test' }, createdAt: Date.now() },
'scope-1': { data: { value: 1 }, createdAt: Date.now() },
'scope-2': { data: { value: 2 }, createdAt: Date.now() },
};
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
},
},
});
// Act
const clearedCount = await service.clearScope();
// Assert
expect(clearedCount).toBe(3);
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
});
});
it('should return 0 when no contexts exist', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const clearedCount = await service.clearScope();
// Assert
expect(clearedCount).toBe(0);
expect(tabServiceMock.patchTabMetadata).not.toHaveBeenCalled();
});
it('should return 0 when no active tab', async () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
// Act
const clearedCount = await service.clearScope();
// Assert
expect(clearedCount).toBe(0);
});
});
describe('clearAll', () => {
it('should clear all contexts for active tab', async () => {
// Arrange
const tabId = 123;
const contexts = {
default: { data: { url: '/test' }, createdAt: Date.now() },
};
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
},
},
});
// Act
await service.clearAll();
// Assert
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
});
});
});
describe('hasContext', () => {
it('should return true when context exists', async () => {
// Arrange
const tabId = 123;
const data = { returnUrl: '/test' };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
default: { data, createdAt: Date.now() },
},
},
},
});
// Act
const result = await service.hasContext();
// Assert
expect(result).toBe(true);
});
it('should return false when context does not exist', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const result = await service.hasContext();
// Assert
expect(result).toBe(false);
});
it('should check custom scope', async () => {
// Arrange
const tabId = 123;
const customScope = 'wizard';
const data = { step: 1 };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
[customScope]: { data, createdAt: Date.now() },
},
},
},
});
// Act
const result = await service.hasContext(customScope);
// Assert
expect(result).toBe(true);
});
});
describe('getContextCount', () => {
it('should return total number of contexts for active tab', async () => {
// Arrange
const tabId = 123;
const contexts = {
default: { data: { url: '/test' }, createdAt: Date.now() },
'scope-1': { data: { value: 1 }, createdAt: Date.now() },
'scope-2': { data: { value: 2 }, createdAt: Date.now() },
};
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
},
},
});
// Act
const count = await service.getContextCount();
// Assert
expect(count).toBe(3);
});
it('should return 0 when no contexts exist', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const count = await service.getContextCount();
// Assert
expect(count).toBe(0);
});
it('should return 0 when no active tab', async () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
// Act
const count = await service.getContextCount();
// Assert
expect(count).toBe(0);
});
});
});

View File

@@ -1,442 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { TabService } from '@isa/core/tabs';
import { logger } from '@isa/core/logging';
import {
NavigationContext,
NavigationContextData,
NavigationContextsMetadataSchema,
} from './navigation-context.types';
import { NAVIGATION_CONTEXT_METADATA_KEY } from './navigation-context.constants';
/**
* Service for managing navigation context using tab metadata storage.
*
* This service provides a type-safe approach to preserving navigation state
* across intermediate navigations, solving the problem of lost router state
* in multi-step flows.
*
* Key Features:
* - Stores contexts in tab metadata (automatic cleanup when tab closes)
* - Type-safe with Zod validation
* - Scoped to individual tabs (no cross-tab pollution)
* - Simple API with hierarchical scoping support
*
* Storage Architecture:
* - Contexts stored at: `tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY]`
* - Structure: `{ [customScope]: { data, createdAt } }`
* - No manual cleanup needed (handled by tab lifecycle)
*
* @example
* ```typescript
* // Start of flow - preserve context (auto-scoped to active tab)
* contextService.setContext({
* returnUrl: '/original-page',
* customerId: 123
* });
*
* // ... intermediate navigations happen ...
*
* // End of flow - restore and cleanup
* const context = contextService.getAndClearContext<{ returnUrl: string }>();
* if (context?.returnUrl) {
* await router.navigateByUrl(context.returnUrl);
* }
* ```
*/
@Injectable({ providedIn: 'root' })
export class NavigationContextService {
readonly #tabService = inject(TabService);
readonly #log = logger(() => ({ module: 'navigation-context' }));
/**
* Get the navigation contexts map from tab metadata.
*
* @param tabId The tab ID to get contexts for
* @returns Record of scope keys to contexts, or empty object if not found
*/
#getContextsMap(tabId: number): Record<string, NavigationContext> {
const tab = this.#tabService.entityMap()[tabId];
if (!tab) {
this.#log.debug('Tab not found', () => ({ tabId }));
return {};
}
const contextsMap = tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY];
if (!contextsMap) {
return {};
}
// Validate with Zod schema
const result = NavigationContextsMetadataSchema.safeParse(contextsMap);
if (!result.success) {
this.#log.warn('Invalid contexts map in tab metadata', () => ({
tabId,
validationErrors: result.error.errors,
}));
return {};
}
return result.data as Record<string, NavigationContext>;
}
/**
* Save the navigation contexts map to tab metadata.
*
* @param tabId The tab ID to save contexts to
* @param contextsMap The contexts map to save
*/
#saveContextsMap(
tabId: number,
contextsMap: Record<string, NavigationContext>,
): void {
this.#tabService.patchTabMetadata(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: contextsMap,
});
}
/**
* Set a context in the active tab's metadata.
*
* Creates or overwrites a navigation context and persists it to tab metadata.
* The context will automatically be cleaned up when the tab is closed.
*
* @template T The type of data being stored in the context
* @param data The navigation data to preserve
* @param customScope Optional custom scope (defaults to 'default')
* @param _ttl Optional TTL parameter (kept for API compatibility but ignored)
*
* @example
* ```typescript
* // Set context for default scope
* contextService.setContext({ returnUrl: '/products', selectedIds: [1, 2, 3] });
*
* // Set context for custom scope
* contextService.setContext({ customerId: 42 }, 'customer-details');
* ```
*/
async setContext<T extends NavigationContextData>(
data: T,
customScope?: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_ttl?: number, // Kept for API compatibility but ignored
): Promise<void> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
throw new Error('No active tab - cannot set navigation context');
}
const scopeKey = customScope || 'default';
const context: NavigationContext = {
data,
createdAt: Date.now(),
};
const contextsMap = this.#getContextsMap(tabId);
contextsMap[scopeKey] = context;
this.#saveContextsMap(tabId, contextsMap);
this.#log.debug('Context set in tab metadata', () => ({
tabId,
scopeKey,
dataKeys: Object.keys(data),
totalContexts: Object.keys(contextsMap).length,
}));
}
/**
* Patch a context in the active tab's metadata.
*
* Merges partial data with the existing context, preserving unspecified properties.
* Properties explicitly set to `undefined` will be removed from the context.
* If the context doesn't exist, creates a new one (behaves like setContext).
*
* @template T The type of data being patched
* @param partialData The partial navigation data to merge
* @param customScope Optional custom scope (defaults to 'default')
*
* @example
* ```typescript
* // Clear one property while preserving others
* contextService.patchContext({ autoTriggerContinueFn: undefined }, 'select-customer');
*
* // Update one property while preserving others
* contextService.patchContext({ selectedTab: 'details' }, 'customer-flow');
* ```
*/
async patchContext<T extends NavigationContextData>(
partialData: Partial<T>,
customScope?: string,
): Promise<void> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
throw new Error('No active tab - cannot patch navigation context');
}
const scopeKey = customScope || 'default';
const existingContext = await this.getContext<T>(customScope);
const mergedData = {
...(existingContext ?? {}),
...partialData,
};
// Remove properties explicitly set to undefined
const removedKeys: string[] = [];
Object.keys(mergedData).forEach((key) => {
if (mergedData[key] === undefined) {
removedKeys.push(key);
delete mergedData[key];
}
});
const contextsMap = this.#getContextsMap(tabId);
const context: NavigationContext = {
data: mergedData,
createdAt:
existingContext && contextsMap[scopeKey]
? contextsMap[scopeKey].createdAt
: Date.now(),
};
contextsMap[scopeKey] = context;
this.#saveContextsMap(tabId, contextsMap);
this.#log.debug('Context patched in tab metadata', () => ({
tabId,
scopeKey,
patchedKeys: Object.keys(partialData),
removedKeys,
totalDataKeys: Object.keys(mergedData),
wasUpdate: existingContext !== null,
totalContexts: Object.keys(contextsMap).length,
}));
}
/**
* Get a context from the active tab's metadata without removing it.
*
* Retrieves a preserved navigation context by scope.
*
* @template T The expected type of the context data
* @param customScope Optional custom scope (defaults to 'default')
* @returns The context data, or null if not found
*
* @example
* ```typescript
* // Get context for default scope
* const context = contextService.getContext<{ returnUrl: string }>();
*
* // Get context for custom scope
* const context = contextService.getContext<{ customerId: number }>('customer-details');
* ```
*/
async getContext<T extends NavigationContextData = NavigationContextData>(
customScope?: string,
): Promise<T | null> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
this.#log.debug('No active tab - cannot get context');
return null;
}
const scopeKey = customScope || 'default';
const contextsMap = this.#getContextsMap(tabId);
const context = contextsMap[scopeKey];
if (!context) {
this.#log.debug('Context not found', () => ({ tabId, scopeKey }));
return null;
}
this.#log.debug('Context retrieved', () => ({
tabId,
scopeKey,
dataKeys: Object.keys(context.data),
}));
return context.data as T;
}
/**
* Get a context from the active tab's metadata and remove it.
*
* Retrieves a preserved navigation context and removes it from the metadata.
* Use this when completing a flow to clean up automatically.
*
* @template T The expected type of the context data
* @param customScope Optional custom scope (defaults to 'default')
* @returns The context data, or null if not found
*
* @example
* ```typescript
* // Get and clear context for default scope
* const context = contextService.getAndClearContext<{ returnUrl: string }>();
* if (context?.returnUrl) {
* await router.navigateByUrl(context.returnUrl);
* }
*
* // Get and clear context for custom scope
* const context = contextService.getAndClearContext<{ customerId: number }>('customer-details');
* ```
*/
async getAndClearContext<
T extends NavigationContextData = NavigationContextData,
>(customScope?: string): Promise<T | null> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
this.#log.debug('No active tab - cannot get and clear context');
return null;
}
const scopeKey = customScope || 'default';
const contextsMap = this.#getContextsMap(tabId);
const context = contextsMap[scopeKey];
if (!context) {
this.#log.debug('Context not found for clearing', () => ({
tabId,
scopeKey,
}));
return null;
}
// Remove from map
delete contextsMap[scopeKey];
this.#saveContextsMap(tabId, contextsMap);
this.#log.debug('Context retrieved and cleared', () => ({
tabId,
scopeKey,
dataKeys: Object.keys(context.data),
remainingContexts: Object.keys(contextsMap).length,
}));
return context.data as T;
}
/**
* Clear a specific context from the active tab's metadata.
*
* Removes a context without returning its data.
* Useful for explicit cleanup without needing the data.
*
* @param customScope Optional custom scope (defaults to 'default')
* @returns true if context was found and cleared, false otherwise
*
* @example
* ```typescript
* // Clear context for default scope
* contextService.clearContext();
*
* // Clear context for custom scope
* contextService.clearContext('customer-details');
* ```
*/
async clearContext(customScope?: string): Promise<boolean> {
const result = await this.getAndClearContext(customScope);
return result !== null;
}
/**
* Clear all contexts for the active tab.
*
* Removes all contexts from the active tab's metadata.
* Useful for cleanup when a workflow is cancelled or completed.
*
* @returns The number of contexts cleared
*
* @example
* ```typescript
* // Clear all contexts for active tab
* const cleared = contextService.clearScope();
* console.log(`Cleared ${cleared} contexts`);
* ```
*/
async clearScope(): Promise<number> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
this.#log.warn('Cannot clear scope: no active tab');
return 0;
}
const contextsMap = this.#getContextsMap(tabId);
const contextCount = Object.keys(contextsMap).length;
if (contextCount === 0) {
return 0;
}
// Clear entire metadata key
this.#tabService.patchTabMetadata(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
});
this.#log.debug('Tab scope cleared', () => ({
tabId,
clearedCount: contextCount,
}));
return contextCount;
}
/**
* Clear all contexts from the active tab (alias for clearScope).
*
* This method is kept for backward compatibility with the previous API.
* It clears all contexts for the active tab only, not globally.
*
* @example
* ```typescript
* contextService.clearAll();
* ```
*/
async clearAll(): Promise<void> {
await this.clearScope();
this.#log.debug('All contexts cleared for active tab');
}
/**
* Check if a context exists for the active tab.
*
* @param customScope Optional custom scope (defaults to 'default')
* @returns true if context exists, false otherwise
*
* @example
* ```typescript
* // Check default scope
* if (contextService.hasContext()) {
* const context = contextService.getContext();
* }
*
* // Check custom scope
* if (contextService.hasContext('customer-details')) {
* const context = contextService.getContext('customer-details');
* }
* ```
*/
async hasContext(customScope?: string): Promise<boolean> {
const context = await this.getContext(customScope);
return context !== null;
}
/**
* Get the current context count for the active tab (for debugging/monitoring).
*
* @returns The total number of contexts in the active tab's metadata
*
* @example
* ```typescript
* const count = await contextService.getContextCount();
* console.log(`Active tab has ${count} contexts`);
* ```
*/
async getContextCount(): Promise<number> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
return 0;
}
const contextsMap = this.#getContextsMap(tabId);
return Object.keys(contextsMap).length;
}
}

Some files were not shown because too many files have changed in this diff Show More