mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Compare commits
32 Commits
feature/en
...
feature/54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0acfb6af1 | ||
|
|
d9b653073b | ||
|
|
de3edaa0f9 | ||
|
|
964a6026a0 | ||
|
|
83ad5f526e | ||
|
|
ccc5285602 | ||
|
|
7200eaefbf | ||
|
|
39e56a275e | ||
|
|
6c41214d69 | ||
|
|
6e55b7b0da | ||
|
|
5711a75188 | ||
|
|
3696fb5b2d | ||
|
|
7e7721b222 | ||
|
|
14be1365bd | ||
|
|
d5324675ef | ||
|
|
f10338a48b | ||
|
|
aa57d27924 | ||
|
|
6cb9aea7d1 | ||
|
|
fdfb54a3a0 | ||
|
|
5f94549539 | ||
|
|
aee63711e4 | ||
|
|
a3c865e39c | ||
|
|
68f50b911d | ||
|
|
0670dbfdb1 | ||
|
|
db4f30af86 | ||
|
|
39b945ae88 | ||
|
|
a2b29c0525 | ||
|
|
2c385210db | ||
|
|
46999cc04c | ||
|
|
ee9f030a99 | ||
|
|
5aded6ff8e | ||
|
|
7884e1af32 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
188
.claude/commands/quality.md
Normal 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
|
||||
@@ -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+)
|
||||
|
||||
|
||||
171
.claude/skills/architecture-documentation/SKILL.md
Normal file
171
.claude/skills/architecture-documentation/SKILL.md
Normal 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
|
||||
@@ -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
|
||||
268
.claude/skills/architecture-documentation/references/arc42.md
Normal file
268
.claude/skills/architecture-documentation/references/arc42.md
Normal 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
|
||||
163
.claude/skills/architecture-documentation/references/c4-model.md
Normal file
163
.claude/skills/architecture-documentation/references/c4-model.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
---
|
||||
|
||||
|
||||
@@ -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`
|
||||
293
.claude/skills/type-safety-engineer/references/zod-patterns.md
Normal file
293
.claude/skills/type-safety-engineer/references/zod-patterns.md
Normal 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
3
.gitignore
vendored
@@ -80,3 +80,6 @@ CLAUDE.md
|
||||
*.pyc
|
||||
.vite
|
||||
reports/
|
||||
|
||||
# Local iPad dev setup (proxy)
|
||||
/local-dev/
|
||||
|
||||
46
CLAUDE.md
46
CLAUDE.md
@@ -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 |
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// start:ng42.barrel
|
||||
export * from './preview.component';
|
||||
// end:ng42.barrel
|
||||
@@ -1,3 +0,0 @@
|
||||
:host {
|
||||
@apply grid min-h-screen content-center justify-center;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
}
|
||||
}
|
||||
<page-shopping-cart-item
|
||||
(changeItem)="changeItem($event)"
|
||||
(changeItem)="showPurchasingListModal([$event.shoppingCartItem])"
|
||||
(changeDummyItem)="changeDummyItem($event)"
|
||||
(changeQuantity)="updateItemQuantity($event)"
|
||||
[quantityError]="
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
48
apps/isa-app/src/ui/modal/dialog/dialog.helper.ts
Normal file
48
apps/isa-app/src/ui/modal/dialog/dialog.helper.ts
Normal 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);
|
||||
};
|
||||
152
apps/isa-app/src/ui/modal/dialog/dialog.model.spec.ts
Normal file
152
apps/isa-app/src/ui/modal/dialog/dialog.model.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -118,6 +118,11 @@ export interface ReceiptDTO extends EntityDTOBaseOfReceiptDTOAndIReceipt{
|
||||
*/
|
||||
receiptNumber?: string;
|
||||
|
||||
/**
|
||||
* Subtype of the receipt / Beleg-Unterart
|
||||
*/
|
||||
receiptSubType?: string;
|
||||
|
||||
/**
|
||||
* Belegtext
|
||||
*/
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: RewardCatalogComponent,
|
||||
title: 'Prämienshop',
|
||||
resolve: { querySettings: querySettingsResolverFn },
|
||||
data: {
|
||||
scrollPositionRestoration: true,
|
||||
|
||||
@@ -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('; ');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ?? [],
|
||||
],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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$);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RewardShoppingCartComponent } from './reward-shopping-cart.component';
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
title: 'Prämienshop - Warenkorb',
|
||||
component: RewardShoppingCartComponent,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
600
libs/common/title-management/README.md
Normal file
600
libs/common/title-management/README.md
Normal 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.
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
libs/common/title-management/src/index.ts
Normal file
3
libs/common/title-management/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './lib/isa-title.strategy';
|
||||
export * from './lib/use-page-title.function';
|
||||
export * from './lib/title-management.types';
|
||||
157
libs/common/title-management/src/lib/isa-title.strategy.spec.ts
Normal file
157
libs/common/title-management/src/lib/isa-title.strategy.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
89
libs/common/title-management/src/lib/isa-title.strategy.ts
Normal file
89
libs/common/title-management/src/lib/isa-title.strategy.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
14
libs/common/title-management/src/lib/title-prefix.ts
Normal file
14
libs/common/title-management/src/lib/title-prefix.ts
Normal 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'));
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
203
libs/common/title-management/src/lib/use-page-title.function.ts
Normal file
203
libs/common/title-management/src/lib/use-page-title.function.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
@@ -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.
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user