Patterns
Exemple de patterns utilisés dans l'application.
Components
Vm$
Pour regrouper plusieurs observables pour utilisation dans le template avec les pipes async
, on les regroupe dans des variables de view/model vm$
.
export class StructureDetailPageComponent {
protected readonly vm$ = combineLatest([
this._service.structure$,
this._service.children$,
this._service.privileges$,
]).pipe(
map(([structure, children, privileges]) => ({
structure,
children,
privileges,
})),
);
}
Ainsi dans le html on peut souscrire en une fois à vm$
à haut niveau et on est sûr que toutes les données sont disponibles et qu'on ne souscrit pas plusieurs fois à chaque observables.
<ng-container *ngIf="vm$ | async as vm">
<structures-detail-header [structure]="vm.structure" />
<ui-layout-main>
<section>
<structures-detail-portage [structure]="vm.structure" />
<structures-detail-rattachements
[structure]="vm.structure"
[structureChildren]="vm.children"
/>
<structures-detail-privileges
[structure]="vm.structure"
[privileges]="vm.privileges"
/>
</section>
</ui-layout-main>
</ng-container>
Attention combineLatest
nécessite que chaque observables internes émettent au moins une fois avant qu'il émette sa première valeur.
Réagir à des observables
Lorsqu'un composant doit faire des calculs avec des objets qui peuvent évoluer (ex: calcul de condition d'affichage d'un bouton), on utilise des observables. Chaque élément pouvant changer qui fait partie de la condition doit faire partie des objets sources écoutés.
//ex: calcul de l'état d'un bouton par rapport à deux objets
export class CircuitListComponent {
private readonly _addViseurState$: Observable<AddViseurState> = combineLatest(
[
this._typologieDetailService.statusSummary$,
this._visaService.viseurs$,
this._visaService.existsRoleViseurAssignable$,
],
).pipe(
map(([statusSummary, viseurs, existsRoleViseurAssignable]) =>
//le calcul de la condition est caché dans une méthode utilitaire (facilement testable)
computeAddViseurState(statusSummary, viseurs, existsRoleViseurAssignable),
),
);
}
Mixer input et observables
Lorsqu'un input doit être combiné avec un observable du composant alors on peut appliquer le pattern du "input backed by subject".
itemsSubject = new BehaviorSubject<Item[]>([]);
@Input() set items(value: Item[]) {
this.itemsSubject.next(value); // les valeurs de l'input sont directement stockées dans un behavior subject
}
sortOrder$ = this.service.sortOrder$;
// la combinaison est réactive
sortedItems$ = combineLatest([
this.itemsSubject.asObservable(),
this.sortOrder$
]).pipe(
map(([items, order]) => {
return [...items].sort(
//...use sort order here
);
})
);
Il est aussi possible d'utiliser ngOnChanges
pour mettre à jour l'observable sortedItems$
manuellement lorsqu'on reçoit une nouvelle valeur pour items
mais c'est du code plus impératif.
ngOnChanges(changes: SimpleChanges): void {
if (changes['items']) {
this.sortedItems$ = this.service.sortOrder$.pipe(
map(order => {
return [...this.items].sort(
//...use sort order here);
)
})
);
}
}
Slots
Lorsqu'un composant propose de projetter du contenu et que plusieurs endroits sont définis, on utilise un attribut nommé slot
pour donner le nom de l'emplacement.
<!-- composant autorisant la projection -->
<div>
...
</div>
<ng-content [slot="errors" ]></ng-content>
<!-- composant parent qui utilise la projection -->
<mon-composant>
<!-- contenu projeté -->
<div slot="errors">...</div>
</mon-composant>
Chargements
Pré-chargement des données
En général on s'assure que les données principales nécessaires à l'affichage d'une page soient pré-chargés :
- pour éviter des problèmes de droits, si une des données n'est pas accessible la navigation vers la page est interrompu en amont
- pour pouvoir afficher directement le contenu sans loader
Dans le cas de pages contenant de nombreux blocs, qui dépendent de droits d'accès différents on s'autorise de charger certaines parties dans un second temps (ex: page de détail d'une convention).
Resolver / Components page
On pré-charge les données avant accès à une page via les resolver. Les données sont typées et la clé d'accès est une constante. Ca permet au service associé de récupérer les données et s'assurer du bon typage / du bon accès via la clé.
export type TypologiesVisasResolverData = {
viseurList: Array<ViseurListItem>;
rolesViseurAssignables: Array<CircuitVisaRole>;
};
export const TYPOLOGIE_VISAS_RESOLVER_KEY = 'visas';
export const typologiesVisasResolver: ResolveFn<TypologiesVisasResolverData> = (
route: ActivatedRouteSnapshot,
) => {
//...
};
On utilise un loader qui couvre toute la page pendant ce chargement.
export const typologiesVisasResolver: ResolveFn<TypologiesVisasResolverData> = (
route: ActivatedRouteSnapshot,
) => {
//...
// englober l'observable retourné par le resolver par withLoader
return withLoader(
forkJoin({
viseurList: visaApi.fetchViseurs(typologieId),
rolesViseurAssignables: visaApi.fetchRolesViseurAssignables(typologieId),
}).pipe(
catchError((err) => {
// handle the promise rejection and completes observable without any value(stops the navigation)
log.debug(err);
return EMPTY;
}),
),
);
}
Modal
L'instantiation d'une modal/popup est différente et passe par un composant dédié.
Lorsque l'on veut charger des données avant affichage du contenu de la popup, on utilise le loader du composant modal.
Il faut pour cela utiliser l'input contentLoading
pour gérer son affichage.
@if (vm$ | async; as vm) {
<shared-modal
[title]="'TYPOLOGIES.DETAIL.TAB_VISA.MODALS.ADD_VISEUR.TITLE' | translate"
[content]="contentTpl"
[contentLoading]="vm.firstContentResult"
[footer]="footerTpl"
>
</shared-modal>
<!-- si on affiche le template #contentTpl, on sait que les donnés sont disponibles (géré dans shared-modal) -->
<ng-template #contentTpl>
<!-- controle supplémentaire/redondant nécessaire pour récupérer les bons types de données avec typescript -->
@if (vm.firstContentResult.status === 'success') {
<!-- acces aux données préchargées ici -->
}
}
Ci-dessous asDataLoadingResult()
s'assure que le contenu passera d'abord par un statut de chargement puis d'un succès ou un échec.
/**
* Load the first content needed for the modal.
*/
private readonly _firstContent$ = combineLatest([
this.rolesViseurAssignables$,
this.fetchHasConvention$,
]).pipe(
map(([rolesViseurAssignables, _]) => ({ rolesViseurAssignables })),
asDataLoadingResult(),
);
protected readonly vm$ = combineLatest([
this._state$.asObservable(),
this._firstContent$,
]).pipe(
map(([state, firstContentResult]) => ({
state,
firstContentResult,
})),
);
On peut donc se servir de ce mécanisme pour initialiser divers éléments du composant qui seront utilisés dans le template.
Ex: Ici on initialise un attribut du composant après l'avoir récupéré depuis un observable.
(fetchHasConvention$
fait partie de l'observable _firstContent$
)
/**
* Fetch the typology data to check if there are conventions.
*/
private readonly fetchHasConvention$ =
this.typologiesDetailService.data$.pipe(
take(1),
tap((data) => (this.hasConvention = data.conventionsCount > 0)),
);
Lorsque l'on utilise une popup, on peut utiliser des valeurs statiques calculées à l'intialisation seulement et non réactives puisque le contexte de la popup est temporaire et empêche tout autre action.
A contrario les composants d'une page doivent être capables de réagir à un changement d'une valeur provoquée par un autre composant.
Ex: Dans le cas de la propriété hasConvention
, on est sûr que les donnés de la convention ne vont pas changer pendant l'utilisation de la popup, on utilise donc une propriété statique.
Dans un composant de pleine page on aurait plutôt défini un observable.
Donnée chargée dynamiquement
Dans certains cas, les données seront chargés à la demande (ex: nouveaux champs d'un formulaire suite à une sélection, contenu affiché/caché).
On a définit un type AsyncResult
et une directive associée ShowAsyncResultDirective
qui permet d'afficher automatiquement un loader puis les données (ou un message d'erreur le cas échéant).
// ce type devient AsyncResult<Array<NomenclatureSimpleItem[]>>
// lors de la souscription, cet observable émettra d'abord un status de chargement puis soit un succès (avec les données) ou une erreur
readonly documentTypes$ = this.nomenclatureApi.getDetailsNomenclaturesValid(
NomenclatureSimpleTable.TYPES_DOCUMENT,
).pipe(
asDataLoadingResult()
)
L'ancienne version utilise une méthode plutôt qu'un Operator mais c'est équivalent (moins bien intégré à RxJS) :
withDataLoadingResult(
this.nomenclatureApi.getDetailsNomenclaturesValid(
NomenclatureSimpleTable.TYPES_DOCUMENT,
),
)
Dans le cas d'une liste, la directive est souvent associé à IfArrayNotEmptyDirective
qui affiche à la place un message warning si la liste est vide.
@if (documentTypes$ | async; as documentTypes) {
<!-- on passe si l'on veut un label de chargement plus spécifique -->
<ng-container
*showAsyncResult="
documentTypes;
loadingLabel: 'SHARED.DOCUMENT_MANAGER.MODAL.DETAIL_UPDATE.TYPES_DOCUMENT_LOADING'
| translate
"
>
<!-- les données résolues sont accessibles dans la propriété data et accessible si le status est un succès -->
<!-- c'est showAsyncResult qui dit à typescript que si le template est affiché alors asyncResult est un succès et donc contient data -->
<ng-container *ifArrayNotEmpty="documentTypes.data">
<select>
@for (type of documentTypes.data; track type.id) {
<option [ngValue]="type.id">
{{ type.libelle }}
</option>
}
</select>
<!-- [...] -->
</ng-container>
</ng-container>
}
Rafraichissement de données
Dans certains cas, des données ont été chargées tôt - par exemple pour conditionner l'accès à une action -.
Dans le cas d'une action menant à l'ouverture d'une popup, on veut parfois rafraichir ces mêmes données pour palier au fait que les données ont peut etre évolués depuis le premier chargement (si on reste longtemps sur une même page).
On stocke en général les données dans un service et à l'ouverture de la popup on appelle une méthode qui rafraichit cette donnée.
Ainsi les observateurs qui utilisait cette donnée sont également mis à jour.
private _rolesViseurAssignablesSubject$ = new BehaviorSubject<Array<CircuitVisaRole>>([]);
private readonly _rolesViseurAssignables$ =
this._rolesViseurAssignablesSubject$.asObservable();
//dans le service on déclare une méthode centralisée qui retournera le même observable et permettra au choix de rafraichir la donnée
getRolesViseurAssignables$(
refresh = false,
): Observable<Array<CircuitVisaRole>> {
if (refresh) {
return this.api
.fetchRolesViseurAssignables(this.detailService.getTypologie().id)
.pipe(
tap((data) => this._rolesViseurAssignablesSubject$.next(data)),
mergeMap(() => this._rolesViseurAssignables$),
);
}
return this._rolesViseurAssignables$;
}
// in a popup
private readonly rolesViseurAssignables$: Observable<Array<CircuitVisaRole>> =
this.service.getRolesViseurAssignables$(true).pipe(take(1));
On utilise take(1)
pour ne récupérer qu'une seule valeur.
En effet entre le temps où l'on soumet le formulaire et que la popup se ferme, l'observable peut émettre une nouvelle valeur suite à l'action et au rafraichissement des données associées.
Si la popup affiche un contenu dépendant de cette valeur (ex: message d'alerte), on peut voir apparaitre brièvement ce contenu juste avant que la popup se ferme.
Gestion des erreurs
Définition
Si le front-end doit afficher certains messages d'erreur spécifiques aux retours d'API, on les définit dans des dictionnaires.
export type TypologieError = 'typologie-1001' | 'typologie-1002';
export const TypologieErrors: ErrorDictionnary<TypologieError> = {
'typologie-1001': {
template:
'Cet intitulé est déjà utilisé.',
},
//exemple avec injection automatique des params provenant du back
'typologie-1002': {
template:
"La recherche retourne plus de {ldap-max-resultat} résultats. Merci d'affiner la recherche.",
contextParams: ['ldap-max-resultat'],
},
};
Chaque API enregistre les codes erreurs qui lui sont associés.
export class TypologieApi extends BaseApi {
constructor(errorManager: ErrorManagerService) {
super();
errorManager.addErrors(TypologieErrors);
}
}
Affichage
On utilise généralement le composant shared-simple-alert
pour afficher les erreurs en tant qu'alerte.
<!-- généralement dans un container pour gérer un espacement homogène avec la classe container-y-spacing -->
<div class="container-y-spacing" *ngIf="error">
<shared-simple-alert
type="error"
[message]="error"
></shared-simple-alert>
</div>
L'erreur est une variable dans le composant et mise à jour, par exemple, lors du retour d'un appel HTTP.
On utilise ErrorFactoryService
pour récupérer le message à afficher. Si des messages d'erreurs spécifiques sont à afficher, on les passe à la méthode getMessageToDisplay
, sinon, on récupérera un message générique.
export class ModalDeleteComponent {
onPrimaryAction() {
this.loading = true;
this._service
.deletePrivilege(this.form.getRawValue())
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: () => {
//...
},
error: (error: ApiError) => {
// on s'assure de typer ces codes pour avoir un suivi par typescript en cas d'évolution (+ on a l'autocomplétion)
const errorCodes: Array<PrivilegeError> = [
'privilege-1003',
'privilege-1004',
];
this.error = this.errorFactory.getMessageToDisplay(error, errorCodes);
},
});
}
}