ObsiViewer/docs/URL_STATE/URL_STATE_SERVICE_INTEGRATION.md
Bruno Charest 96745e9997 feat: add URL state synchronization for navigation
- Added UrlStateService to sync app state with URL parameters for note selection, tags, folders, and search
- Implemented URL state effects in AppComponent to handle navigation from URL parameters
- Updated sidebar and layout components to reflect URL state changes in UI
- Added URL state updates when navigating via note selection, tag clicks, and search
- Modified note sharing to use URL parameters instead of route paths
- Added auto-opening of relevant
2025-10-24 23:23:30 -04:00

621 lines
14 KiB
Markdown

# UrlStateService - Guide d'Intégration Complet
## 📋 Table des matières
1. [Vue d'ensemble](#vue-densemble)
2. [Installation](#installation)
3. [Intégration dans les composants](#intégration-dans-les-composants)
4. [Exemples d'URL](#exemples-durl)
5. [Gestion des erreurs](#gestion-des-erreurs)
6. [Cas d'usage avancés](#cas-dusage-avancés)
7. [API Complète](#api-complète)
---
## Vue d'ensemble
Le `UrlStateService` synchronise l'état de l'interface avec l'URL, permettant:
-**Deep-linking**: Ouvrir une note directement via URL
-**Partage de liens**: Générer des URLs partageables
-**Restauration d'état**: Retrouver l'état après rechargement
-**Filtrage persistant**: Tags, dossiers, quick links via URL
-**Recherche persistante**: Termes de recherche dans l'URL
### Architecture
```
URL (query params)
UrlStateService (parsing + validation)
Angular Signals (currentState, activeTag, etc.)
Composants (NotesListComponent, NoteViewComponent, etc.)
```
---
## Installation
### 1. Service déjà créé
Le service est disponible à:
```
src/app/services/url-state.service.ts
```
### 2. Injection dans AppComponent
```typescript
import { Component, inject } from '@angular/core';
import { UrlStateService } from './services/url-state.service';
@Component({
selector: 'app-root',
standalone: true,
template: `...`
})
export class AppComponent {
private urlStateService = inject(UrlStateService);
// Le service est automatiquement initialisé et écoute les changements d'URL
}
```
---
## Intégration dans les composants
### NotesListComponent - Synchroniser les filtres avec l'URL
```typescript
import { Component, inject, effect } from '@angular/core';
import { UrlStateService } from '../../services/url-state.service';
@Component({
selector: 'app-notes-list',
standalone: true,
template: `...`
})
export class NotesListComponent {
private urlState = inject(UrlStateService);
// Signaux dérivés de l'URL
activeTag = this.urlState.activeTag;
activeFolder = this.urlState.activeFolder;
activeQuickLink = this.urlState.activeQuickLink;
activeSearch = this.urlState.activeSearch;
constructor() {
// Écouter les changements d'état
effect(() => {
const state = this.urlState.currentState();
// Réagir aux changements
if (state.tag) {
console.log('Tag filter applied:', state.tag);
this.applyTagFilter(state.tag);
}
if (state.folder) {
console.log('Folder filter applied:', state.folder);
this.applyFolderFilter(state.folder);
}
if (state.search) {
console.log('Search applied:', state.search);
this.applySearch(state.search);
}
});
}
// Mettre à jour l'URL quand l'utilisateur change de vue
onTagClick(tag: string): void {
this.urlState.filterByTag(tag);
}
onFolderClick(folder: string): void {
this.urlState.filterByFolder(folder);
}
onQuickLinkClick(quickLink: string): void {
this.urlState.filterByQuickLink(quickLink);
}
onSearch(searchTerm: string): void {
this.urlState.updateSearch(searchTerm);
}
}
```
### NoteViewComponent - Ouvrir une note via URL
```typescript
import { Component, inject, effect } from '@angular/core';
import { UrlStateService } from '../../services/url-state.service';
import { VaultService } from '../../../services/vault.service';
@Component({
selector: 'app-note-view',
standalone: true,
template: `
<div *ngIf="currentNote() as note" class="note-view">
<h1>{{ note.title }}</h1>
<div [innerHTML]="note.content"></div>
</div>
`
})
export class NoteViewComponent {
private urlState = inject(UrlStateService);
private vault = inject(VaultService);
// Signal de la note actuelle depuis l'URL
currentNote = this.urlState.currentNote;
constructor() {
// Charger la note quand l'URL change
effect(async () => {
const note = this.currentNote();
if (note) {
// Charger le contenu complet si nécessaire
await this.vault.ensureNoteLoadedByPath(note.filePath);
}
});
}
// Ouvrir une note en mettant à jour l'URL
openNote(notePath: string): void {
this.urlState.openNote(notePath);
}
}
```
### FoldersSidebarComponent - Synchroniser la sélection avec l'URL
```typescript
import { Component, inject } from '@angular/core';
import { UrlStateService } from '../../services/url-state.service';
@Component({
selector: 'app-folders-sidebar',
standalone: true,
template: `
<div class="folders-list">
<button *ngFor="let folder of folders"
[class.active]="urlState.isFolderActive(folder.path)"
(click)="selectFolder(folder.path)">
{{ folder.name }}
</button>
</div>
`
})
export class FoldersSidebarComponent {
urlState = inject(UrlStateService);
selectFolder(folderPath: string): void {
this.urlState.filterByFolder(folderPath);
}
}
```
### TagsComponent - Synchroniser les tags avec l'URL
```typescript
import { Component, inject } from '@angular/core';
import { UrlStateService } from '../../services/url-state.service';
@Component({
selector: 'app-tags-view',
standalone: true,
template: `
<div class="tags-list">
<button *ngFor="let tag of tags"
[class.active]="urlState.isTagActive(tag.name)"
(click)="selectTag(tag.name)">
#{{ tag.name }}
</button>
</div>
`
})
export class TagsComponent {
urlState = inject(UrlStateService);
selectTag(tagName: string): void {
this.urlState.filterByTag(tagName);
}
}
```
---
## Exemples d'URL
### 1. Ouvrir une note spécifique
```
https://app.example.com/viewer?note=Docs/Architecture.md
```
**Résultat**: Ouvre la note `Docs/Architecture.md` dans la vue note
### 2. Filtrer par tag
```
https://app.example.com/viewer?tag=Ideas
```
**Résultat**: Affiche toutes les notes avec le tag `Ideas`
### 3. Filtrer par dossier
```
https://app.example.com/viewer?folder=Notes/Meetings
```
**Résultat**: Affiche toutes les notes du dossier `Notes/Meetings`
### 4. Afficher un quick link
```
https://app.example.com/viewer?quick=Favoris
```
**Résultat**: Affiche les notes marquées comme favoris
### 5. Rechercher
```
https://app.example.com/viewer?search=performance
```
**Résultat**: Affiche les résultats de recherche pour "performance"
### 6. Combinaisons
```
https://app.example.com/viewer?note=Docs/Architecture.md&search=performance
```
**Résultat**: Ouvre la note et met en surbrillance les occurrences de "performance"
```
https://app.example.com/viewer?folder=Notes/Meetings&tag=Important
```
**Résultat**: Affiche les notes du dossier `Notes/Meetings` avec le tag `Important`
---
## Gestion des erreurs
### Note introuvable
```typescript
async openNote(notePath: string): Promise<void> {
try {
await this.urlState.openNote(notePath);
} catch (error) {
console.error('Note not found:', notePath);
// Afficher un message d'erreur à l'utilisateur
this.toast.error(`Note introuvable: ${notePath}`);
// Réinitialiser l'état
this.urlState.resetState();
}
}
```
### Tag inexistant
```typescript
async filterByTag(tag: string): Promise<void> {
try {
await this.urlState.filterByTag(tag);
} catch (error) {
console.error('Tag not found:', tag);
this.toast.warning(`Tag inexistant: ${tag}`);
}
}
```
### Dossier inexistant
```typescript
async filterByFolder(folder: string): Promise<void> {
try {
await this.urlState.filterByFolder(folder);
} catch (error) {
console.error('Folder not found:', folder);
this.toast.warning(`Dossier inexistant: ${folder}`);
}
}
```
---
## Cas d'usage avancés
### 1. Générer un lien de partage
```typescript
// Copier l'URL actuelle
async shareCurrentState(): Promise<void> {
try {
await this.urlState.copyCurrentUrlToClipboard();
this.toast.success('Lien copié!');
} catch (error) {
this.toast.error('Erreur lors de la copie');
}
}
// Générer une URL personnalisée
generateShareUrl(note: Note): string {
return this.urlState.generateShareUrl({ note: note.filePath });
}
```
### 2. Écouter les changements d'état
```typescript
constructor() {
// Écouter tous les changements
this.urlState.stateChange$.subscribe(event => {
console.log('État précédent:', event.previous);
console.log('Nouvel état:', event.current);
console.log('Propriétés changées:', event.changed);
});
// Écouter un changement spécifique
this.urlState.onStatePropertyChange('note').subscribe(event => {
console.log('Note changée:', event.current.note);
});
this.urlState.onStatePropertyChange('tag').subscribe(event => {
console.log('Tag changé:', event.current.tag);
});
}
```
### 3. Restaurer l'état après rechargement
```typescript
constructor() {
// L'état est automatiquement restauré depuis l'URL
effect(() => {
const state = this.urlState.currentState();
// Restaurer la vue
if (state.note) {
this.openNote(state.note);
} else if (state.tag) {
this.filterByTag(state.tag);
} else if (state.folder) {
this.filterByFolder(state.folder);
} else if (state.quick) {
this.filterByQuickLink(state.quick);
}
});
}
```
### 4. Historique de navigation
```typescript
// Obtenir l'état précédent
const previousState = this.urlState.getPreviousState();
// Revenir à l'état précédent
if (previousState.note) {
this.urlState.openNote(previousState.note);
} else if (previousState.tag) {
this.urlState.filterByTag(previousState.tag);
}
```
### 5. Réinitialiser l'état
```typescript
// Retour à la vue par défaut
resetToDefault(): void {
this.urlState.resetState();
}
```
---
## API Complète
### Signaux (Computed)
```typescript
// État actuel
currentState: Signal<UrlState>
// État précédent
previousState: Signal<UrlState>
// Note actuellement ouverte
currentNote: Signal<Note | null>
// Tag actif
activeTag: Signal<string | null>
// Dossier actif
activeFolder: Signal<string | null>
// Quick link actif
activeQuickLink: Signal<string | null>
// Terme de recherche actif
activeSearch: Signal<string | null>
```
### Méthodes
#### Navigation
```typescript
// Ouvrir une note
async openNote(notePath: string): Promise<void>
// Filtrer par tag
async filterByTag(tag: string): Promise<void>
// Filtrer par dossier
async filterByFolder(folder: string): Promise<void>
// Filtrer par quick link
async filterByQuickLink(quickLink: string): Promise<void>
// Mettre à jour la recherche
async updateSearch(searchTerm: string): Promise<void>
// Réinitialiser l'état
async resetState(): Promise<void>
```
#### Vérification
```typescript
// Vérifier si une note est ouverte
isNoteOpen(notePath: string): boolean
// Vérifier si un tag est actif
isTagActive(tag: string): boolean
// Vérifier si un dossier est actif
isFolderActive(folder: string): boolean
// Vérifier si un quick link est actif
isQuickLinkActive(quickLink: string): boolean
```
#### Partage
```typescript
// Générer une URL partageble
generateShareUrl(state?: Partial<UrlState>): string
// Copier l'URL actuelle
async copyCurrentUrlToClipboard(): Promise<void>
```
#### État
```typescript
// Obtenir l'état actuel
getState(): UrlState
// Obtenir l'état précédent
getPreviousState(): UrlState
```
### Observables
```typescript
// Observable des changements d'état
stateChange$: Observable<UrlStateChangeEvent>
// Observable des changements d'une propriété
onStatePropertyChange(property: keyof UrlState): Observable<UrlStateChangeEvent>
```
### Types
```typescript
interface UrlState {
note?: string; // Chemin de la note
tag?: string; // Tag de filtrage
folder?: string; // Dossier de filtrage
quick?: string; // Quick link de filtrage
search?: string; // Terme de recherche
}
interface UrlStateChangeEvent {
previous: UrlState;
current: UrlState;
changed: (keyof UrlState)[];
}
```
---
## Checklist d'intégration
- [ ] Service `UrlStateService` créé
- [ ] Service injecté dans `AppComponent`
- [ ] `NotesListComponent` synchronise les filtres avec l'URL
- [ ] `NoteViewComponent` ouvre les notes via URL
- [ ] `FoldersSidebarComponent` synchronise la sélection avec l'URL
- [ ] `TagsComponent` synchronise les tags avec l'URL
- [ ] Gestion des erreurs implémentée
- [ ] Tests unitaires écrits
- [ ] Documentation mise à jour
- [ ] Déploiement en production
---
## Troubleshooting
### L'URL ne change pas quand je change de vue
**Solution**: Vérifiez que vous appelez les méthodes du service:
```typescript
// ❌ Mauvais
this.currentTag = 'Ideas';
// ✅ Correct
await this.urlState.filterByTag('Ideas');
```
### La note n'est pas trouvée
**Solution**: Vérifiez le chemin exact:
```typescript
// Afficher tous les chemins disponibles
console.log(this.vault.allNotes().map(n => n.filePath));
// Utiliser le bon chemin
await this.urlState.openNote('Docs/Architecture.md');
```
### L'état n'est pas restauré après rechargement
**Solution**: Assurez-vous que le service est injecté dans `AppComponent`:
```typescript
export class AppComponent {
private urlStateService = inject(UrlStateService);
// Le service s'initialise automatiquement
}
```
---
## Performance
- ✅ Utilise Angular Signals pour les mises à jour réactives
- ✅ Pas de polling, écoute les changements d'URL natifs
- ✅ Décodage/encodage URI optimisé
- ✅ Gestion automatique du cycle de vie
---
## Sécurité
- ✅ Validation des chemins de notes
- ✅ Validation des tags existants
- ✅ Validation des dossiers existants
- ✅ Encodage URI pour les caractères spéciaux
- ✅ Pas d'exécution de code depuis l'URL
---
## Prochaines étapes
1. **Tests unitaires**: Créer des tests pour chaque méthode
2. **Tests E2E**: Tester les flux complets avec Playwright
3. **Monitoring**: Tracker les URLs les plus utilisées
4. **Analytics**: Analyser les patterns de navigation
5. **Optimisation**: Compresser les URLs longues si nécessaire