mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merge branch 'develop' into feature/5202-Praemie
This commit is contained in:
@@ -1,7 +1,517 @@
|
||||
# core-storage
|
||||
# @isa/core/storage
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
A powerful, type-safe storage library for Angular applications built on top of NgRx Signals. This library provides seamless integration between NgRx Signal Stores and various storage backends including localStorage, sessionStorage, IndexedDB, and server-side user state.
|
||||
|
||||
## Running unit tests
|
||||
## Features
|
||||
|
||||
Run `nx test core-storage` to execute the unit tests.
|
||||
- 🔄 **Auto-sync with NgRx Signals**: Seamlessly integrates with NgRx Signal Stores
|
||||
- 🏪 **Multiple Storage Providers**: Support for localStorage, sessionStorage, IndexedDB, memory, and server-side storage
|
||||
- 🚀 **Auto-save with Debouncing**: Automatically persist state changes with configurable debouncing
|
||||
- 👤 **User-scoped Storage**: Automatic user-specific storage keys using OAuth identity claims
|
||||
- 🔒 **Type-safe**: Full TypeScript support with Zod schema validation
|
||||
- 🎛️ **Configurable**: Flexible configuration options for different use cases
|
||||
- 🧩 **Extensible**: Easy to add custom storage providers
|
||||
|
||||
## Installation
|
||||
|
||||
This library is part of the ISA Frontend monorepo and is already available as `@isa/core/storage`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { signalStore, withState } from '@ngrx/signals';
|
||||
import { withStorage, LocalStorageProvider } from '@isa/core/storage';
|
||||
|
||||
// Create a store with automatic localStorage persistence
|
||||
const UserPreferencesStore = signalStore(
|
||||
withState({ theme: 'dark', language: 'en' }),
|
||||
withStorage('user-preferences', LocalStorageProvider)
|
||||
);
|
||||
|
||||
// The store will automatically:
|
||||
// 1. Load saved state on initialization
|
||||
// 2. Provide manual save/load methods
|
||||
// 3. Auto-save state changes (if enabled)
|
||||
```
|
||||
|
||||
## Storage Providers
|
||||
|
||||
### LocalStorageProvider
|
||||
Persists data to the browser's localStorage (survives browser restarts).
|
||||
|
||||
```typescript
|
||||
import { LocalStorageProvider } from '@isa/core/storage';
|
||||
|
||||
const store = signalStore(
|
||||
withState({ count: 0 }),
|
||||
withStorage('counter', LocalStorageProvider)
|
||||
);
|
||||
```
|
||||
|
||||
### SessionStorageProvider
|
||||
Persists data to sessionStorage (cleared when tab closes).
|
||||
|
||||
```typescript
|
||||
import { SessionStorageProvider } from '@isa/core/storage';
|
||||
|
||||
const store = signalStore(
|
||||
withState({ tempData: null }),
|
||||
withStorage('session-data', SessionStorageProvider)
|
||||
);
|
||||
```
|
||||
|
||||
### IDBStorageProvider
|
||||
Uses IndexedDB for larger data storage with better performance.
|
||||
|
||||
```typescript
|
||||
import { IDBStorageProvider } from '@isa/core/storage';
|
||||
|
||||
const store = signalStore(
|
||||
withState({ largeDataSet: [] }),
|
||||
withStorage('large-data', IDBStorageProvider)
|
||||
);
|
||||
```
|
||||
|
||||
### UserStorageProvider
|
||||
Server-side storage tied to the authenticated user's account.
|
||||
|
||||
```typescript
|
||||
import { UserStorageProvider } from '@isa/core/storage';
|
||||
|
||||
const store = signalStore(
|
||||
withState({ userSettings: {} }),
|
||||
withStorage('user-settings', UserStorageProvider)
|
||||
);
|
||||
```
|
||||
|
||||
### MemoryStorageProvider
|
||||
In-memory storage for testing or temporary data.
|
||||
|
||||
```typescript
|
||||
import { MemoryStorageProvider } from '@isa/core/storage';
|
||||
|
||||
const store = signalStore(
|
||||
withState({ testData: null }),
|
||||
withStorage('test-data', MemoryStorageProvider)
|
||||
);
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The `withStorage` function accepts an optional configuration object:
|
||||
|
||||
```typescript
|
||||
interface WithStorageConfig {
|
||||
autosave?: boolean; // Enable automatic state persistence (default: false)
|
||||
debounceTime?: number; // Debounce time in milliseconds (default: 300)
|
||||
autoload?: boolean; // Enable automatic state loading on initialization (default: true)
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Save/Load with Auto-load (Default Behavior)
|
||||
|
||||
```typescript
|
||||
const store = signalStore(
|
||||
withState({ data: 'initial' }),
|
||||
withStorage('my-data', LocalStorageProvider)
|
||||
// Default: autoload = true, autosave = false
|
||||
);
|
||||
|
||||
// State is automatically loaded on initialization
|
||||
// Manual save/load operations available
|
||||
store.saveToStorage(); // Save current state
|
||||
store.loadFromStorage(); // Manually reload state
|
||||
```
|
||||
|
||||
### Disable Auto-load for Full Manual Control
|
||||
|
||||
```typescript
|
||||
const store = signalStore(
|
||||
withState({ data: 'initial' }),
|
||||
withStorage('my-data', LocalStorageProvider, {
|
||||
autoload: false // Disable automatic loading
|
||||
})
|
||||
);
|
||||
|
||||
// Manual operations only
|
||||
store.saveToStorage(); // Save current state
|
||||
store.loadFromStorage(); // Load and restore state
|
||||
```
|
||||
|
||||
### Auto-save with Auto-load
|
||||
|
||||
```typescript
|
||||
const store = signalStore(
|
||||
withState({ count: 0 }),
|
||||
withStorage('counter', LocalStorageProvider, {
|
||||
autosave: true
|
||||
// autoload: true (default) - state loaded on initialization
|
||||
// Will auto-save with 300ms debounce
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Auto-save with Custom Debouncing
|
||||
|
||||
```typescript
|
||||
const store = signalStore(
|
||||
withState({ settings: {} }),
|
||||
withStorage('settings', LocalStorageProvider, {
|
||||
autosave: true,
|
||||
autoload: true, // Load saved settings on initialization (default)
|
||||
debounceTime: 1000 // Save 1 second after last change
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Tab Management with Auto-save
|
||||
|
||||
```typescript
|
||||
// From libs/core/tabs/src/lib/tab.ts
|
||||
export const TabService = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withDevtools('TabService'),
|
||||
withStorage('tabs', UserStorageProvider), // Server-side user storage
|
||||
withState<{ activatedTabId: number | null }>({
|
||||
activatedTabId: null,
|
||||
}),
|
||||
withEntities<Tab>(),
|
||||
// ... other features
|
||||
);
|
||||
```
|
||||
|
||||
### Shopping Cart with Auto-persistence
|
||||
|
||||
```typescript
|
||||
const ShoppingCartStore = signalStore(
|
||||
withState<{ items: CartItem[], total: number }>({
|
||||
items: [],
|
||||
total: 0
|
||||
}),
|
||||
withStorage('shopping-cart', LocalStorageProvider, {
|
||||
autosave: true,
|
||||
debounceTime: 500 // Save 500ms after changes stop
|
||||
}),
|
||||
withMethods((store) => ({
|
||||
addItem(item: CartItem) {
|
||||
const items = [...store.items(), item];
|
||||
const total = items.reduce((sum, item) => sum + item.price, 0);
|
||||
patchState(store, { items, total });
|
||||
// State automatically saved after 500ms
|
||||
},
|
||||
removeItem(id: string) {
|
||||
const items = store.items().filter(item => item.id !== id);
|
||||
const total = items.reduce((sum, item) => sum + item.price, 0);
|
||||
patchState(store, { items, total });
|
||||
// State automatically saved after 500ms
|
||||
}
|
||||
}))
|
||||
);
|
||||
```
|
||||
|
||||
### User Preferences with Manual Control
|
||||
|
||||
```typescript
|
||||
const UserPreferencesStore = signalStore(
|
||||
withState({
|
||||
theme: 'light' as 'light' | 'dark',
|
||||
language: 'en',
|
||||
notifications: true
|
||||
}),
|
||||
withStorage('user-preferences', LocalStorageProvider),
|
||||
withMethods((store) => ({
|
||||
updateTheme(theme: 'light' | 'dark') {
|
||||
patchState(store, { theme });
|
||||
store.saveToStorage(); // Manual save
|
||||
},
|
||||
resetToDefaults() {
|
||||
patchState(store, {
|
||||
theme: 'light',
|
||||
language: 'en',
|
||||
notifications: true
|
||||
});
|
||||
store.saveToStorage(); // Manual save
|
||||
}
|
||||
}))
|
||||
);
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### User-scoped Storage Keys
|
||||
|
||||
The library automatically creates user-specific storage keys when using any storage provider:
|
||||
|
||||
```typescript
|
||||
// Internal key generation (user sub: "user123", key: "settings")
|
||||
// Results in: "user123:a1b2c3" (where a1b2c3 is a hash of "settings")
|
||||
```
|
||||
|
||||
This ensures that different users' data never conflicts, even on shared devices.
|
||||
|
||||
### Schema Validation with Zod
|
||||
|
||||
The underlying `Storage` class supports optional Zod schema validation:
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
import { injectStorage, LocalStorageProvider } from '@isa/core/storage';
|
||||
|
||||
const UserSchema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number()
|
||||
});
|
||||
|
||||
// In a service or component
|
||||
const storage = injectStorage(LocalStorageProvider);
|
||||
|
||||
// Type-safe get with validation
|
||||
const userData = storage.get('user', UserSchema);
|
||||
// userData is properly typed as z.infer<typeof UserSchema>
|
||||
```
|
||||
|
||||
### Custom Storage Provider
|
||||
|
||||
Create your own storage provider by implementing the `StorageProvider` interface:
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@angular/core';
|
||||
import { StorageProvider } from '@isa/core/storage';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CustomStorageProvider implements StorageProvider {
|
||||
async init?(): Promise<void> {
|
||||
// Optional initialization logic
|
||||
}
|
||||
|
||||
async reload?(): Promise<void> {
|
||||
// Optional reload logic
|
||||
}
|
||||
|
||||
set(key: string, value: unknown): void {
|
||||
// Your storage implementation
|
||||
console.log(`Saving ${key}:`, value);
|
||||
}
|
||||
|
||||
get(key: string): unknown {
|
||||
// Your retrieval implementation
|
||||
console.log(`Loading ${key}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
// Your clear implementation
|
||||
console.log(`Clearing ${key}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `withStorage(storageKey, storageProvider, config?)`
|
||||
|
||||
NgRx Signals store feature that adds storage capabilities.
|
||||
|
||||
**Parameters:**
|
||||
- `storageKey: string` - Unique key for storing data
|
||||
- `storageProvider: Type<StorageProvider>` - Storage provider class
|
||||
- `config?: WithStorageConfig` - Optional configuration
|
||||
|
||||
**Returns:**
|
||||
- `SignalStoreFeature` with added methods:
|
||||
- `saveToStorage()` - Manually save current state
|
||||
- `loadFromStorage()` - Manually load and apply stored state
|
||||
|
||||
### `injectStorage(storageProvider)`
|
||||
|
||||
Injectable function to get a storage instance.
|
||||
|
||||
**Parameters:**
|
||||
- `storageProvider: Type<StorageProvider>` - Storage provider class
|
||||
|
||||
**Returns:**
|
||||
- `Storage` instance with methods:
|
||||
- `set<T>(key, value)` - Store value
|
||||
- `get<T>(key, schema?)` - Retrieve value with optional validation
|
||||
- `clear(key)` - Remove value
|
||||
|
||||
### Storage Providers
|
||||
|
||||
All storage providers implement the `StorageProvider` interface:
|
||||
|
||||
```typescript
|
||||
interface StorageProvider {
|
||||
init?(): Promise<void>; // Optional initialization
|
||||
reload?(): Promise<void>; // Optional reload
|
||||
set(key: string, value: unknown): void; // Store value
|
||||
get(key: string): unknown; // Retrieve value
|
||||
clear(key: string): void; // Remove value
|
||||
}
|
||||
```
|
||||
|
||||
**Available Providers:**
|
||||
- `LocalStorageProvider` - Browser localStorage
|
||||
- `SessionStorageProvider` - Browser sessionStorage
|
||||
- `IDBStorageProvider` - IndexedDB
|
||||
- `UserStorageProvider` - Server-side user storage
|
||||
- `MemoryStorageProvider` - In-memory storage
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Choose the Right Storage Provider
|
||||
|
||||
- **LocalStorageProvider**: User preferences, settings that should persist across sessions
|
||||
- **SessionStorageProvider**: Temporary data that should be cleared when tab closes
|
||||
- **IDBStorageProvider**: Large datasets, complex objects, better performance needs
|
||||
- **UserStorageProvider**: Cross-device synchronization, server-backed user data
|
||||
- **MemoryStorageProvider**: Testing, temporary data during app lifecycle
|
||||
|
||||
### 2. Configure Auto-save and Auto-load Wisely
|
||||
|
||||
```typescript
|
||||
// For frequent changes (like form inputs)
|
||||
withStorage('form-draft', LocalStorageProvider, {
|
||||
autosave: true,
|
||||
autoload: true, // Restore draft on page reload
|
||||
debounceTime: 1000 // Longer debounce
|
||||
})
|
||||
|
||||
// For infrequent changes (like settings)
|
||||
withStorage('user-settings', LocalStorageProvider, {
|
||||
autosave: true,
|
||||
autoload: true, // Load saved settings immediately
|
||||
debounceTime: 100 // Shorter debounce
|
||||
})
|
||||
|
||||
// For critical data that needs manual control
|
||||
withStorage('important-data', LocalStorageProvider, {
|
||||
autoload: false // Disable automatic loading
|
||||
})
|
||||
// Use manual saveToStorage() and loadFromStorage() for precise control
|
||||
```
|
||||
|
||||
### 3. Handle Storage Errors
|
||||
|
||||
Storage operations can fail (quota exceeded, network issues, etc.). The library handles errors gracefully:
|
||||
|
||||
- Failed saves are logged to console but don't throw
|
||||
- Failed loads return undefined/null
|
||||
- State continues to work in memory even if storage fails
|
||||
|
||||
### 4. Consider Storage Size Limits
|
||||
|
||||
- **localStorage/sessionStorage**: ~5-10MB per domain
|
||||
- **IndexedDB**: Much larger, varies by browser and device
|
||||
- **UserStorageProvider**: Depends on server configuration
|
||||
|
||||
### 5. Test with Different Storage Providers
|
||||
|
||||
Use `MemoryStorageProvider` in tests for predictable, isolated behavior:
|
||||
|
||||
```typescript
|
||||
// In tests
|
||||
const testStore = signalStore(
|
||||
withState({ data: 'test' }),
|
||||
withStorage('test-key', MemoryStorageProvider)
|
||||
);
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
The library consists of several key components:
|
||||
|
||||
1. **Storage Class**: Core storage abstraction with user-scoping
|
||||
2. **StorageProvider Interface**: Pluggable storage backends
|
||||
3. **withStorage Feature**: NgRx Signals integration
|
||||
4. **Hash Utilities**: Efficient key generation
|
||||
5. **User Token**: OAuth-based user identification
|
||||
|
||||
The architecture promotes:
|
||||
- **Separation of Concerns**: Storage logic separate from business logic
|
||||
- **Type Safety**: Full TypeScript support throughout
|
||||
- **Extensibility**: Easy to add new storage providers
|
||||
- **User Privacy**: Automatic user-scoping prevents data leaks
|
||||
- **Performance**: Debounced saves prevent excessive writes
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Manual localStorage
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
localStorage.setItem('settings', JSON.stringify(settings));
|
||||
const settings = JSON.parse(localStorage.getItem('settings') || '{}');
|
||||
|
||||
// After
|
||||
const SettingsStore = signalStore(
|
||||
withState(defaultSettings),
|
||||
withStorage('settings', LocalStorageProvider, { autosave: true })
|
||||
);
|
||||
```
|
||||
|
||||
### From Custom Storage Services
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
@Injectable()
|
||||
class SettingsService {
|
||||
private settings = signal(defaultSettings);
|
||||
|
||||
save() {
|
||||
localStorage.setItem('settings', JSON.stringify(this.settings()));
|
||||
}
|
||||
|
||||
load() {
|
||||
const data = localStorage.getItem('settings');
|
||||
if (data) this.settings.set(JSON.parse(data));
|
||||
}
|
||||
}
|
||||
|
||||
// After
|
||||
const SettingsStore = signalStore(
|
||||
withState(defaultSettings),
|
||||
withStorage('settings', LocalStorageProvider, { autosave: true })
|
||||
);
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Storage not persisting**: Check if storage provider supports your environment
|
||||
2. **Data not loading**: Verify storage key consistency
|
||||
3. **Performance issues**: Adjust debounce time or switch storage providers
|
||||
4. **User data conflicts**: Ensure USER_SUB token is properly configured
|
||||
|
||||
### Debug Mode
|
||||
|
||||
The storage feature uses the centralized `@isa/core/logging` system. All storage operations are logged with appropriate context including the storage key, autosave settings, and operation details.
|
||||
|
||||
To see debug logs, configure the logging system at the application level:
|
||||
|
||||
```typescript
|
||||
// The storage feature automatically logs:
|
||||
// - Debug: Successful operations, state loading/saving
|
||||
// - Warn: Validation failures, data type issues
|
||||
// - Error: Storage failures, fallback application errors
|
||||
|
||||
const store = signalStore(
|
||||
withState({ data: null }),
|
||||
withStorage('my-data', LocalStorageProvider)
|
||||
);
|
||||
// All operations will be logged with context: { module: 'storage', storageKey: 'my-data', ... }
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run unit tests with:
|
||||
|
||||
```bash
|
||||
nx test core-storage
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This library is part of the ISA Frontend project and follows the project's licensing terms.
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Type } from '@angular/core';
|
||||
import { Type, effect, DestroyRef, inject } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
getState,
|
||||
patchState,
|
||||
@@ -6,27 +7,266 @@ import {
|
||||
withHooks,
|
||||
withMethods,
|
||||
} from '@ngrx/signals';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { z } from 'zod';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { StorageProvider } from './storage-providers';
|
||||
import { injectStorage } from './storage';
|
||||
|
||||
export function withStorage(
|
||||
export interface WithStorageConfig<T = object> {
|
||||
autosave?: boolean; // default: false
|
||||
debounceTime?: number; // default: 300
|
||||
autoload?: boolean; // default: true
|
||||
validator?: (data: unknown) => data is T; // Custom validation function
|
||||
schema?: z.ZodType<T>; // Zod schema for validation
|
||||
fallbackState?: Partial<T>; // Fallback state when validation fails
|
||||
excludeProperties?: string[]; // Properties to exclude from storage
|
||||
}
|
||||
|
||||
export function withStorage<T extends object>(
|
||||
storageKey: string,
|
||||
storageProvider: Type<StorageProvider>,
|
||||
config: WithStorageConfig<T> = {},
|
||||
) {
|
||||
// Input validation
|
||||
if (
|
||||
!storageKey ||
|
||||
typeof storageKey !== 'string' ||
|
||||
storageKey.trim() === ''
|
||||
) {
|
||||
throw new Error(`Invalid storage key: ${storageKey}`);
|
||||
}
|
||||
|
||||
if (!storageProvider) {
|
||||
throw new Error('Storage provider is required');
|
||||
}
|
||||
|
||||
const {
|
||||
autosave = false,
|
||||
debounceTime: debounceTimeMs = 300,
|
||||
autoload = true,
|
||||
validator,
|
||||
schema,
|
||||
fallbackState,
|
||||
excludeProperties = [],
|
||||
} = config;
|
||||
|
||||
// Validate configuration
|
||||
if (debounceTimeMs < 0) {
|
||||
throw new Error('Debounce time must be non-negative');
|
||||
}
|
||||
|
||||
return signalStoreFeature(
|
||||
withMethods((store, storage = injectStorage(storageProvider)) => ({
|
||||
storeState: () => storage.set(storageKey, getState(store)),
|
||||
restoreState: async () => {
|
||||
const data = await storage.get(storageKey);
|
||||
if (data && typeof data === 'object') {
|
||||
patchState(store, data);
|
||||
}
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
storage = injectStorage(storageProvider),
|
||||
log = logger(() => ({
|
||||
module: 'storage',
|
||||
storageKey,
|
||||
autosave,
|
||||
autoload,
|
||||
debounceTime: debounceTimeMs,
|
||||
})),
|
||||
) => ({
|
||||
saveToStorage: () => {
|
||||
try {
|
||||
const state = getState(store);
|
||||
|
||||
// Filter out excluded properties if specified
|
||||
const filteredState =
|
||||
excludeProperties.length > 0
|
||||
? Object.fromEntries(
|
||||
Object.entries(state).filter(
|
||||
([key]) => !excludeProperties.includes(key),
|
||||
),
|
||||
)
|
||||
: state;
|
||||
|
||||
storage.set(storageKey, filteredState);
|
||||
log.debug('Successfully saved state');
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'Failed to save state',
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
throw new Error(
|
||||
`Storage save failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
loadFromStorage: () => {
|
||||
try {
|
||||
const data = storage.get(storageKey);
|
||||
|
||||
if (!data) {
|
||||
log.debug('No data found in storage');
|
||||
if (fallbackState && Object.keys(fallbackState).length > 0) {
|
||||
patchState(store, fallbackState);
|
||||
log.debug('Applied fallback state');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enhanced validation
|
||||
if (
|
||||
typeof data !== 'object' ||
|
||||
data === null ||
|
||||
Array.isArray(data)
|
||||
) {
|
||||
log.warn('Invalid data type in storage', () => ({
|
||||
dataType: typeof data,
|
||||
}));
|
||||
if (fallbackState && Object.keys(fallbackState).length > 0) {
|
||||
patchState(store, fallbackState);
|
||||
log.debug('Applied fallback state due to invalid data type');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Zod schema validation
|
||||
if (schema) {
|
||||
try {
|
||||
const validatedData = schema.parse(data);
|
||||
patchState(store, validatedData);
|
||||
log.debug('Successfully loaded and validated state');
|
||||
return;
|
||||
} catch (validationError) {
|
||||
log.warn('Schema validation failed', () => ({
|
||||
validationError,
|
||||
}));
|
||||
if (fallbackState && Object.keys(fallbackState).length > 0) {
|
||||
patchState(store, fallbackState);
|
||||
log.debug(
|
||||
'Applied fallback state due to schema validation failure',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validator function
|
||||
if (validator && !validator(data)) {
|
||||
log.warn('Custom validation failed');
|
||||
if (fallbackState && Object.keys(fallbackState).length > 0) {
|
||||
patchState(store, fallbackState);
|
||||
log.debug(
|
||||
'Applied fallback state due to custom validation failure',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic validation passed - apply state
|
||||
patchState(store, data as Partial<T>);
|
||||
log.debug('Successfully loaded state');
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'Failed to load state',
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
if (fallbackState && Object.keys(fallbackState).length > 0) {
|
||||
try {
|
||||
patchState(store, fallbackState);
|
||||
log.debug('Applied fallback state due to load error');
|
||||
} catch (fallbackError) {
|
||||
log.error(
|
||||
'Failed to apply fallback state',
|
||||
fallbackError instanceof Error ? fallbackError : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Don't throw here as we want the app to continue working even if load fails
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
withHooks(
|
||||
(
|
||||
store,
|
||||
log = logger(() => ({
|
||||
module: 'storage',
|
||||
storageKey,
|
||||
autosave,
|
||||
autoload,
|
||||
debounceTime: debounceTimeMs,
|
||||
})),
|
||||
) => {
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
return {
|
||||
onInit() {
|
||||
// Load initial state if autoload is enabled
|
||||
if (autoload) {
|
||||
try {
|
||||
store.loadFromStorage();
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'Failed to load initial state',
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (autosave) {
|
||||
const destroyRef = inject(DestroyRef);
|
||||
const saveSubject = new Subject<void>();
|
||||
|
||||
const subscription = saveSubject
|
||||
.pipe(
|
||||
debounceTime(debounceTimeMs),
|
||||
takeUntilDestroyed(destroyRef),
|
||||
)
|
||||
.subscribe(() => {
|
||||
try {
|
||||
store.saveToStorage();
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'Autosave failed',
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
// Don't rethrow - keep autosave running
|
||||
}
|
||||
});
|
||||
|
||||
const effectRef = effect(() => {
|
||||
try {
|
||||
getState(store);
|
||||
saveSubject.next();
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'Effect error in autosave',
|
||||
error instanceof Error ? error : undefined,
|
||||
);
|
||||
// Don't rethrow - keep effect running
|
||||
}
|
||||
});
|
||||
|
||||
// Set up comprehensive cleanup
|
||||
cleanup = () => {
|
||||
if (!subscription.closed) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
if (!saveSubject.closed) {
|
||||
saveSubject.complete();
|
||||
}
|
||||
effectRef.destroy();
|
||||
};
|
||||
|
||||
// Register cleanup with DestroyRef
|
||||
destroyRef.onDestroy(() => {
|
||||
cleanup?.();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onDestroy() {
|
||||
// Additional cleanup hook
|
||||
cleanup?.();
|
||||
},
|
||||
};
|
||||
},
|
||||
})),
|
||||
withHooks((store) => ({
|
||||
onInit() {
|
||||
store.restoreState();
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +1,96 @@
|
||||
import { inject, Injectable, resource, ResourceStatus } from '@angular/core';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
import { UserStateService } from '@generated/swagger/isa-api';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { filter, firstValueFrom, retry, switchMap, tap, timer } from 'rxjs';
|
||||
import { USER_SUB } from '../tokens';
|
||||
import { Debounce, ValidateParam } from '@isa/common/decorators';
|
||||
import z from 'zod';
|
||||
|
||||
type UserState = Record<string, unknown>;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserStorageProvider implements StorageProvider {
|
||||
#userStateService = inject(UserStateService);
|
||||
#userSub = inject(USER_SUB);
|
||||
#userSub = toObservable(inject(USER_SUB));
|
||||
|
||||
#userStateResource = resource<UserState, void>({
|
||||
params: () => this.#userSub(),
|
||||
loader: async () => {
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.#userStateService.UserStateGetUserState(),
|
||||
);
|
||||
if (res?.result?.content) {
|
||||
return JSON.parse(res.result.content);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user state:', error);
|
||||
#loadUserState = this.#userSub.pipe(
|
||||
filter((sub) => sub !== 'anonymous'),
|
||||
switchMap(() =>
|
||||
this.#userStateService.UserStateGetUserState().pipe(
|
||||
retry({
|
||||
count: 3,
|
||||
delay: (error, retryCount) => {
|
||||
console.warn(
|
||||
`Retrying to load user state, attempt #${retryCount}`,
|
||||
error,
|
||||
);
|
||||
return timer(1000 * retryCount); // Exponential backoff with timer
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
tap((res) => {
|
||||
if (res?.result?.content) {
|
||||
this.#state = JSON.parse(res.result.content);
|
||||
}
|
||||
return {};
|
||||
},
|
||||
defaultValue: {},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
#setState(state: UserState) {
|
||||
this.#userStateResource.set(state);
|
||||
this.#postNewState(state);
|
||||
#state: UserState = {};
|
||||
|
||||
async init() {
|
||||
await firstValueFrom(this.#loadUserState);
|
||||
}
|
||||
|
||||
#postNewState(state: UserState) {
|
||||
async reload(): Promise<void> {
|
||||
await firstValueFrom(this.#loadUserState);
|
||||
}
|
||||
|
||||
#setCurrentState(state: UserState) {
|
||||
const newState = structuredClone(state);
|
||||
Object.freeze(newState);
|
||||
this.#state = newState;
|
||||
}
|
||||
|
||||
#setState(state: UserState) {
|
||||
this.#setCurrentState(state);
|
||||
this.postNewState();
|
||||
}
|
||||
|
||||
@Debounce({ wait: 1000 })
|
||||
private postNewState(): void {
|
||||
firstValueFrom(
|
||||
this.#userStateService.UserStateSetUserState({
|
||||
content: JSON.stringify(state),
|
||||
content: JSON.stringify(this.#state),
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.error('Error saving user state:', error);
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.#userStateInitialized();
|
||||
}
|
||||
|
||||
@ValidateParam(0, z.string().min(1))
|
||||
set(key: string, value: Record<string, unknown>): void {
|
||||
console.log('Setting user state key:', key, value);
|
||||
const current = this.#userStateResource.value();
|
||||
const current = this.#state;
|
||||
const content = structuredClone(current);
|
||||
content[key] = value;
|
||||
|
||||
this.#setState(content);
|
||||
}
|
||||
|
||||
@ValidateParam(0, z.string().min(1))
|
||||
get(key: string): unknown {
|
||||
console.log('Getting user state key:', key);
|
||||
return this.#userStateResource.value()[key];
|
||||
return structuredClone(this.#state[key]);
|
||||
}
|
||||
|
||||
@ValidateParam(0, z.string().min(1))
|
||||
clear(key: string): void {
|
||||
const current = this.#userStateResource.value();
|
||||
const current = this.#state;
|
||||
if (key in current) {
|
||||
const content = structuredClone(current);
|
||||
delete content[key];
|
||||
this.#setState(content);
|
||||
}
|
||||
}
|
||||
|
||||
reload(): Promise<void> {
|
||||
this.#userStateResource.reload();
|
||||
|
||||
const reloadPromise = new Promise<void>((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (!this.#userStateResource.isLoading()) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
return reloadPromise;
|
||||
}
|
||||
|
||||
#userStateInitialized() {
|
||||
return new Promise<ResourceStatus>((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (
|
||||
this.#userStateResource.status() === 'resolved' ||
|
||||
this.#userStateResource.status() === 'error'
|
||||
) {
|
||||
clearInterval(check);
|
||||
resolve(this.#userStateResource.status());
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { inject, InjectionToken } from '@angular/core';
|
||||
import { inject, InjectionToken, signal, Signal } from '@angular/core';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
|
||||
export const USER_SUB = new InjectionToken<() => string>(
|
||||
export const USER_SUB = new InjectionToken<Signal<string>>(
|
||||
'core.storage.user-sub',
|
||||
{
|
||||
factory: () => {
|
||||
const auth = inject(OAuthService, { optional: true });
|
||||
return () => auth?.getIdentityClaims()?.['sub'] ?? 'anonymous';
|
||||
return signal(auth?.getIdentityClaims()?.['sub'] ?? 'anonymous');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -64,15 +64,20 @@ export type TabMetadata = z.infer<typeof TabMetadataSchema>;
|
||||
* Allows individual tabs to override global history limits and behavior.
|
||||
* Uses passthrough() to preserve other metadata properties not defined here.
|
||||
*/
|
||||
export const TabMetadataWithHistorySchema = z.object({
|
||||
/** Override for maximum history size (1-1000 entries) */
|
||||
maxHistorySize: z.number().min(1).max(1000).optional(),
|
||||
/** Override for maximum forward history (0-100 entries) */
|
||||
maxForwardHistory: z.number().min(0).max(100).optional(),
|
||||
}).passthrough().default({});
|
||||
export const TabMetadataWithHistorySchema = z
|
||||
.object({
|
||||
/** Override for maximum history size (1-1000 entries) */
|
||||
maxHistorySize: z.number().min(1).max(1000).optional(),
|
||||
/** Override for maximum forward history (0-100 entries) */
|
||||
maxForwardHistory: z.number().min(0).max(100).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.default({});
|
||||
|
||||
/** TypeScript type for metadata with history configuration */
|
||||
export type TabMetadataWithHistory = z.infer<typeof TabMetadataWithHistorySchema>;
|
||||
export type TabMetadataWithHistory = z.infer<
|
||||
typeof TabMetadataWithHistorySchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Schema for tab tags (array of strings).
|
||||
@@ -94,7 +99,7 @@ export const TabSchema = z.object({
|
||||
/** Unique identifier for the tab */
|
||||
id: z.number(),
|
||||
/** Display name for the tab (minimum 1 character) */
|
||||
name: z.string().min(1),
|
||||
name: z.string().default('Neuer Vorgang'),
|
||||
/** Creation timestamp (milliseconds since epoch) */
|
||||
createdAt: z.number(),
|
||||
/** Last activation timestamp (optional) */
|
||||
@@ -159,22 +164,24 @@ export interface TabCreate {
|
||||
* Ensures tabs loaded from sessionStorage/localStorage have all required
|
||||
* properties with strict validation (no extra properties allowed).
|
||||
*/
|
||||
export const PersistedTabSchema = z.object({
|
||||
/** Required unique identifier */
|
||||
id: z.number(),
|
||||
/** Tab display name */
|
||||
name: z.string().min(1),
|
||||
/** Creation timestamp */
|
||||
createdAt: z.number(),
|
||||
/** Last activation timestamp */
|
||||
activatedAt: z.number().optional(),
|
||||
/** Custom metadata */
|
||||
metadata: TabMetadataSchema,
|
||||
/** Navigation history */
|
||||
location: TabLocationHistorySchema,
|
||||
/** Organization tags */
|
||||
tags: TabTagsSchema,
|
||||
}).strict();
|
||||
export const PersistedTabSchema = z
|
||||
.object({
|
||||
/** Required unique identifier */
|
||||
id: z.number(),
|
||||
/** Tab display name */
|
||||
name: z.string().default('Neuer Vorgang'),
|
||||
/** Creation timestamp */
|
||||
createdAt: z.number(),
|
||||
/** Last activation timestamp */
|
||||
activatedAt: z.number().optional(),
|
||||
/** Custom metadata */
|
||||
metadata: TabMetadataSchema,
|
||||
/** Navigation history */
|
||||
location: TabLocationHistorySchema,
|
||||
/** Organization tags */
|
||||
tags: TabTagsSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Input type for TabSchema (before validation) */
|
||||
export type TabInput = z.input<typeof TabSchema>;
|
||||
@@ -187,7 +194,7 @@ export type TabInput = z.input<typeof TabSchema>;
|
||||
*/
|
||||
export const AddTabSchema = z.object({
|
||||
/** Display name for the new tab */
|
||||
name: z.string().min(1),
|
||||
name: z.string().default('Neuer Vorgang'),
|
||||
/** Initial tags for the tab */
|
||||
tags: TabTagsSchema,
|
||||
/** Initial metadata for the tab */
|
||||
@@ -210,18 +217,20 @@ export type AddTabInput = z.input<typeof AddTabSchema>;
|
||||
* Defines optional properties that can be updated on existing tabs.
|
||||
* All properties are optional to support partial updates.
|
||||
*/
|
||||
export const TabUpdateSchema = z.object({
|
||||
/** Updated display name */
|
||||
name: z.string().min(1).optional(),
|
||||
/** Updated activation timestamp */
|
||||
activatedAt: z.number().optional(),
|
||||
/** Updated metadata object */
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
/** Updated location history */
|
||||
location: TabLocationHistorySchema.optional(),
|
||||
/** Updated tags array */
|
||||
tags: z.array(z.string()).optional(),
|
||||
}).strict();
|
||||
export const TabUpdateSchema = z
|
||||
.object({
|
||||
/** Updated display name */
|
||||
name: z.string().min(1).optional(),
|
||||
/** Updated activation timestamp */
|
||||
activatedAt: z.number().optional(),
|
||||
/** Updated metadata object */
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
/** Updated location history */
|
||||
location: TabLocationHistorySchema.optional(),
|
||||
/** Updated tags array */
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** TypeScript type for tab updates */
|
||||
export type TabUpdate = z.infer<typeof TabUpdateSchema>;
|
||||
@@ -232,10 +241,12 @@ export type TabUpdate = z.infer<typeof TabUpdateSchema>;
|
||||
* Specifically validates activation timestamp updates when
|
||||
* switching between tabs.
|
||||
*/
|
||||
export const TabActivationUpdateSchema = z.object({
|
||||
/** New activation timestamp */
|
||||
activatedAt: z.number(),
|
||||
}).strict();
|
||||
export const TabActivationUpdateSchema = z
|
||||
.object({
|
||||
/** New activation timestamp */
|
||||
activatedAt: z.number(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** TypeScript type for activation updates */
|
||||
export type TabActivationUpdate = z.infer<typeof TabActivationUpdateSchema>;
|
||||
@@ -245,10 +256,12 @@ export type TabActivationUpdate = z.infer<typeof TabActivationUpdateSchema>;
|
||||
*
|
||||
* Validates metadata-only updates to avoid affecting other tab properties.
|
||||
*/
|
||||
export const TabMetadataUpdateSchema = z.object({
|
||||
/** Updated metadata object */
|
||||
metadata: z.record(z.unknown()),
|
||||
}).strict();
|
||||
export const TabMetadataUpdateSchema = z
|
||||
.object({
|
||||
/** Updated metadata object */
|
||||
metadata: z.record(z.unknown()),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** TypeScript type for metadata updates */
|
||||
export type TabMetadataUpdate = z.infer<typeof TabMetadataUpdateSchema>;
|
||||
@@ -258,10 +271,12 @@ export type TabMetadataUpdate = z.infer<typeof TabMetadataUpdateSchema>;
|
||||
*
|
||||
* Validates navigation history updates when tabs navigate to new locations.
|
||||
*/
|
||||
export const TabLocationUpdateSchema = z.object({
|
||||
/** Updated location history */
|
||||
location: TabLocationHistorySchema,
|
||||
}).strict();
|
||||
export const TabLocationUpdateSchema = z
|
||||
.object({
|
||||
/** Updated location history */
|
||||
location: TabLocationHistorySchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** TypeScript type for location updates */
|
||||
export type TabLocationUpdate = z.infer<typeof TabLocationUpdateSchema>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NavigationEnd, Router, UrlTree } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { TabService } from './tab';
|
||||
import { TabLocation } from './schemas';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
|
||||
/**
|
||||
* Service that automatically syncs browser navigation events to tab location history.
|
||||
@@ -26,7 +27,7 @@ import { TabLocation } from './schemas';
|
||||
export class TabNavigationService {
|
||||
#router = inject(Router);
|
||||
#tabService = inject(TabService);
|
||||
#document = inject(DOCUMENT);
|
||||
#title = inject(Title);
|
||||
|
||||
constructor() {
|
||||
this.#initializeNavigationSync();
|
||||
@@ -87,35 +88,7 @@ export class TabNavigationService {
|
||||
}
|
||||
|
||||
#getPageTitle(): string {
|
||||
// Try document title first
|
||||
if (this.#document.title && this.#document.title !== 'ISA') {
|
||||
return this.#document.title;
|
||||
}
|
||||
|
||||
// Fallback to extracting from URL or using generic title
|
||||
const urlSegments = this.#router.url
|
||||
.split('/')
|
||||
.filter((segment) => segment);
|
||||
const lastSegment = urlSegments[urlSegments.length - 1];
|
||||
|
||||
switch (lastSegment) {
|
||||
case 'dashboard':
|
||||
return 'Dashboard';
|
||||
case 'product':
|
||||
return 'Produktkatalog';
|
||||
case 'customer':
|
||||
return 'Kundensuche';
|
||||
case 'cart':
|
||||
return 'Warenkorb';
|
||||
case 'order':
|
||||
return 'Kundenbestellungen';
|
||||
default:
|
||||
return lastSegment ? this.#capitalizeFirst(lastSegment) : 'Seite';
|
||||
}
|
||||
}
|
||||
|
||||
#capitalizeFirst(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
return this.#title.getTitle();
|
||||
}
|
||||
|
||||
#isLocationInHistory(
|
||||
|
||||
@@ -2,28 +2,34 @@ import { ResolveFn } from '@angular/router';
|
||||
import { TabService } from './tab';
|
||||
import { Tab } from './schemas';
|
||||
import { inject } from '@angular/core';
|
||||
import { TabNavigationService } from './tab-navigation.service';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
export const tabResolverFn: ResolveFn<Tab> = (route) => {
|
||||
const id = parseInt(route.params['tabId']);
|
||||
const log = logger(() => ({
|
||||
context: 'tabResolverFn',
|
||||
url: route.url.map((s) => s.path).join('/'),
|
||||
params: JSON.stringify(route.params),
|
||||
queryParams: JSON.stringify(route.queryParams),
|
||||
}));
|
||||
const tabId = parseInt(route.params['tabId']);
|
||||
const tabService = inject(TabService);
|
||||
const navigationService = inject(TabNavigationService);
|
||||
|
||||
let tab = tabService.entityMap()[id];
|
||||
if (!tabId || isNaN(tabId) || Number.MAX_SAFE_INTEGER < tabId) {
|
||||
log.error('Invalid tabId', { tabId });
|
||||
throw new Error('Invalid tabId');
|
||||
}
|
||||
|
||||
let tab = tabService.entityMap()[tabId];
|
||||
|
||||
if (!tab) {
|
||||
tab = tabService.addTab({
|
||||
id: tabId,
|
||||
name: 'Neuer Vorgang',
|
||||
});
|
||||
}
|
||||
|
||||
tabService.activateTab(tab.id);
|
||||
|
||||
// Sync current route to tab location history
|
||||
setTimeout(() => {
|
||||
navigationService.syncCurrentRoute();
|
||||
}, 0);
|
||||
|
||||
return tab;
|
||||
};
|
||||
|
||||
@@ -32,6 +38,5 @@ export const processResolverFn: ResolveFn<Tab> = async (route) => {
|
||||
const tabService = inject(TabService);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const id = parseInt(route.params['tabId']);
|
||||
|
||||
return tabService.entityMap()[id];
|
||||
};
|
||||
|
||||
@@ -2,13 +2,11 @@ import {
|
||||
patchState,
|
||||
signalStore,
|
||||
withComputed,
|
||||
withHooks,
|
||||
withMethods,
|
||||
withProps,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import {
|
||||
addEntities,
|
||||
addEntity,
|
||||
removeEntity,
|
||||
updateEntity,
|
||||
@@ -22,25 +20,32 @@ import {
|
||||
Tab,
|
||||
TabLocation,
|
||||
TabLocationHistory,
|
||||
PersistedTabSchema,
|
||||
} from './schemas';
|
||||
import { TAB_CONFIG } from './tab-config';
|
||||
import { TabHistoryPruner } from './tab-history-pruning';
|
||||
import { computed, effect, inject } from '@angular/core';
|
||||
import { computed, inject } from '@angular/core';
|
||||
import { withDevtools } from '@angular-architects/ngrx-toolkit';
|
||||
import { CORE_TAB_ID_GENERATOR } from './tab-id.generator';
|
||||
import { withStorage, UserStorageProvider } from '@isa/core/storage';
|
||||
|
||||
export const TabService = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withDevtools('TabService'),
|
||||
withStorage('tabs', UserStorageProvider, { autosave: true }),
|
||||
withState<{ activatedTabId: number | null }>({
|
||||
activatedTabId: null,
|
||||
}),
|
||||
withEntities<Tab>(),
|
||||
withProps((_, idGenerator = inject(CORE_TAB_ID_GENERATOR), config = inject(TAB_CONFIG)) => ({
|
||||
_generateId: idGenerator,
|
||||
_config: config,
|
||||
})),
|
||||
withProps(
|
||||
(
|
||||
_,
|
||||
idGenerator = inject(CORE_TAB_ID_GENERATOR),
|
||||
config = inject(TAB_CONFIG),
|
||||
) => ({
|
||||
_generateId: idGenerator,
|
||||
_config: config,
|
||||
}),
|
||||
),
|
||||
withComputed((store) => ({
|
||||
activatedTab: computed<Tab | null>(() => {
|
||||
const activeTabId = store.activatedTabId();
|
||||
@@ -106,13 +111,15 @@ export const TabService = signalStore(
|
||||
|
||||
// First, limit forward history if configured
|
||||
const maxForwardHistory =
|
||||
(currentTab.metadata as any)?.maxForwardHistory ?? store._config.maxForwardHistory;
|
||||
(currentTab.metadata as any)?.maxForwardHistory ??
|
||||
store._config.maxForwardHistory;
|
||||
|
||||
const { locations: limitedLocations } = TabHistoryPruner.pruneForwardHistory(
|
||||
currentLocation.locations,
|
||||
currentLocation.current,
|
||||
maxForwardHistory
|
||||
);
|
||||
const { locations: limitedLocations } =
|
||||
TabHistoryPruner.pruneForwardHistory(
|
||||
currentLocation.locations,
|
||||
currentLocation.current,
|
||||
maxForwardHistory,
|
||||
);
|
||||
|
||||
// Add new location
|
||||
const newLocations: TabLocation[] = [
|
||||
@@ -129,12 +136,14 @@ export const TabService = signalStore(
|
||||
const pruningResult = TabHistoryPruner.pruneHistory(
|
||||
newLocationHistory,
|
||||
store._config,
|
||||
currentTab.metadata as any
|
||||
currentTab.metadata as any,
|
||||
);
|
||||
|
||||
if (pruningResult.entriesRemoved > 0) {
|
||||
if (store._config.logPruning) {
|
||||
console.log(`Tab ${id}: Pruned ${pruningResult.entriesRemoved} entries using ${pruningResult.strategy} strategy`);
|
||||
console.log(
|
||||
`Tab ${id}: Pruned ${pruningResult.entriesRemoved} entries using ${pruningResult.strategy} strategy`,
|
||||
);
|
||||
}
|
||||
|
||||
newLocationHistory = {
|
||||
@@ -144,13 +153,16 @@ export const TabService = signalStore(
|
||||
}
|
||||
|
||||
// Validate index integrity
|
||||
const { index: validatedCurrent, wasInvalid } = TabHistoryPruner.validateLocationIndex(
|
||||
newLocationHistory.locations,
|
||||
newLocationHistory.current
|
||||
);
|
||||
const { index: validatedCurrent, wasInvalid } =
|
||||
TabHistoryPruner.validateLocationIndex(
|
||||
newLocationHistory.locations,
|
||||
newLocationHistory.current,
|
||||
);
|
||||
|
||||
if (wasInvalid && store._config.enableIndexValidation) {
|
||||
console.warn(`Tab ${id}: Invalid location index corrected from ${newLocationHistory.current} to ${validatedCurrent}`);
|
||||
console.warn(
|
||||
`Tab ${id}: Invalid location index corrected from ${newLocationHistory.current} to ${validatedCurrent}`,
|
||||
);
|
||||
newLocationHistory.current = validatedCurrent;
|
||||
}
|
||||
|
||||
@@ -168,10 +180,11 @@ export const TabService = signalStore(
|
||||
const currentLocation = currentTab.location;
|
||||
|
||||
// Validate current index before navigation
|
||||
const { index: validatedCurrent } = TabHistoryPruner.validateLocationIndex(
|
||||
currentLocation.locations,
|
||||
currentLocation.current
|
||||
);
|
||||
const { index: validatedCurrent } =
|
||||
TabHistoryPruner.validateLocationIndex(
|
||||
currentLocation.locations,
|
||||
currentLocation.current,
|
||||
);
|
||||
|
||||
if (validatedCurrent <= 0) return null;
|
||||
|
||||
@@ -195,13 +208,13 @@ export const TabService = signalStore(
|
||||
const currentLocation = currentTab.location;
|
||||
|
||||
// Validate current index before navigation
|
||||
const { index: validatedCurrent } = TabHistoryPruner.validateLocationIndex(
|
||||
currentLocation.locations,
|
||||
currentLocation.current
|
||||
);
|
||||
const { index: validatedCurrent } =
|
||||
TabHistoryPruner.validateLocationIndex(
|
||||
currentLocation.locations,
|
||||
currentLocation.current,
|
||||
);
|
||||
|
||||
if (validatedCurrent >= currentLocation.locations.length - 1)
|
||||
return null;
|
||||
if (validatedCurrent >= currentLocation.locations.length - 1) return null;
|
||||
|
||||
const newCurrent = validatedCurrent + 1;
|
||||
const nextLocation = currentLocation.locations[newCurrent];
|
||||
@@ -235,13 +248,16 @@ export const TabService = signalStore(
|
||||
const currentLocation = currentTab.location;
|
||||
|
||||
// Validate current index
|
||||
const { index: validatedCurrent, wasInvalid } = TabHistoryPruner.validateLocationIndex(
|
||||
currentLocation.locations,
|
||||
currentLocation.current
|
||||
);
|
||||
const { index: validatedCurrent, wasInvalid } =
|
||||
TabHistoryPruner.validateLocationIndex(
|
||||
currentLocation.locations,
|
||||
currentLocation.current,
|
||||
);
|
||||
|
||||
if (wasInvalid && store._config.enableIndexValidation) {
|
||||
console.warn(`Tab ${id}: Invalid location index corrected in getCurrentLocation from ${currentLocation.current} to ${validatedCurrent}`);
|
||||
console.warn(
|
||||
`Tab ${id}: Invalid location index corrected in getCurrentLocation from ${currentLocation.current} to ${validatedCurrent}`,
|
||||
);
|
||||
|
||||
// Correct the invalid index in store
|
||||
const changes: Partial<Tab> = {
|
||||
@@ -253,7 +269,10 @@ export const TabService = signalStore(
|
||||
patchState(store, updateEntity({ id, changes }));
|
||||
}
|
||||
|
||||
if (validatedCurrent < 0 || validatedCurrent >= currentLocation.locations.length) {
|
||||
if (
|
||||
validatedCurrent < 0 ||
|
||||
validatedCurrent >= currentLocation.locations.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -290,27 +309,4 @@ export const TabService = signalStore(
|
||||
return updatedLocation;
|
||||
},
|
||||
})),
|
||||
withHooks((store) => ({
|
||||
onInit() {
|
||||
const entitiesStr = sessionStorage.getItem('TabEntities');
|
||||
if (entitiesStr) {
|
||||
const entities = JSON.parse(entitiesStr);
|
||||
const validatedEntities = z.array(PersistedTabSchema).parse(entities);
|
||||
const tabEntities: Tab[] = validatedEntities.map(entity => ({
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
createdAt: entity.createdAt,
|
||||
activatedAt: entity.activatedAt,
|
||||
metadata: entity.metadata,
|
||||
location: entity.location,
|
||||
tags: entity.tags,
|
||||
}));
|
||||
patchState(store, addEntities(tabEntities));
|
||||
}
|
||||
effect(() => {
|
||||
const state = store.entities();
|
||||
sessionStorage.setItem('TabEntities', JSON.stringify(state));
|
||||
});
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user