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

14 KiB

UrlStateService - Guide d'Intégration Complet

📋 Table des matières

  1. Vue d'ensemble
  2. Installation
  3. Intégration dans les composants
  4. Exemples d'URL
  5. Gestion des erreurs
  6. Cas d'usage avancés
  7. 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

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

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

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

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

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

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

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

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

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

// 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

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

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

// 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

// Retour à la vue par défaut
resetToDefault(): void {
  this.urlState.resetState();
}

API Complète

Signaux (Computed)

// É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

// 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

// 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

// Générer une URL partageble
generateShareUrl(state?: Partial<UrlState>): string

// Copier l'URL actuelle
async copyCurrentUrlToClipboard(): Promise<void>

État

// Obtenir l'état actuel
getState(): UrlState

// Obtenir l'état précédent
getPreviousState(): UrlState

Observables

// Observable des changements d'état
stateChange$: Observable<UrlStateChangeEvent>

// Observable des changements d'une propriété
onStatePropertyChange(property: keyof UrlState): Observable<UrlStateChangeEvent>

Types

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:

// ❌ Mauvais
this.currentTag = 'Ideas';

// ✅ Correct
await this.urlState.filterByTag('Ideas');

La note n'est pas trouvée

Solution: Vérifiez le chemin exact:

// 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:

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