diff --git a/src/lib/services/entities-map.service.ts b/src/lib/services/entities-map.service.ts new file mode 100644 index 0000000..031e557 --- /dev/null +++ b/src/lib/services/entities-map.service.ts @@ -0,0 +1,108 @@ +import { Inject, Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { filter, startWith } from 'rxjs/operators'; +import { Entity } from '../listing'; +import { RequiredParam, shareLast, Validate } from '../utils'; + +@Injectable({ providedIn: 'root' }) +export abstract class EntitiesMapService, I> { + protected readonly _map = new Map>(); + private readonly _entityChanged$ = new Subject(); + private readonly _entityDeleted$ = new Subject(); + + protected constructor(@Inject('ENTITY_PRIMARY_KEY') protected readonly _primaryKey: string) {} + + get$(key: string) { + if (!this._map.has(key)) { + this._map.set(key, new BehaviorSubject([])); + } + + return this._getBehaviourSubject(key).asObservable(); + } + + has(dossierId: string) { + return this._map.has(dossierId); + } + + get(key: string): E[]; + get(key: string, id: string): E | undefined; + get(key: string, id?: string): E | E[] | undefined { + const value = this._getBehaviourSubject(key)?.value; + if (!id) { + return value ?? []; + } + return value?.find(item => item.id === id); + } + + set(key: string, entities: E[]): void { + if (!this._map.has(key)) { + this._map.set(key, new BehaviorSubject(entities)); + return entities.forEach(entity => this._entityChanged$.next(entity)); + } + + const changedEntities: E[] = []; + const deletedEntities = this.get(key).filter(oldEntity => !entities.find(newEntity => newEntity.id === oldEntity.id)); + + // Keep old object references for unchanged entities + const newEntities: E[] = entities.map(newEntity => { + const oldEntity: E | undefined = this.get(key, newEntity.id); + + if (oldEntity && newEntity.isEqual(oldEntity)) { + return oldEntity; + } + + changedEntities.push(newEntity); + return newEntity; + }); + + this._getBehaviourSubject(key).next(newEntities); + + // Emit observables only after entities have been updated + + for (const entity of changedEntities) { + this._entityChanged$.next(entity); + } + + for (const entity of deletedEntities) { + this._entityDeleted$.next(entity); + } + } + + replace(entities: E[]) { + const key = this._pluckPrimaryKey(entities[0]); + const entityIds = entities.map(entity => entity.id); + const existingEntities = this.get(key).filter(entity => entityIds.includes(entity.id)); + const newEntities = entities.filter(entity => { + const old = existingEntities.find(e => e.id === entity.id); + return !old || !entity.isEqual(old); + }); + if (newEntities.length) { + const all = this.get(key).filter(e => !newEntities.map(entity => entity.id).includes(e.id)); + this.set(key, [...all, ...newEntities]); + } + } + + @Validate() + watch$(@RequiredParam() key: string, @RequiredParam() entityId: string): Observable { + return this._entityChanged$.pipe( + filter(entity => entity.id === entityId), + startWith(this.get(key, entityId) as E), + shareLast(), + ); + } + + watchDeleted$(entityId: string): Observable { + return this._entityDeleted$.pipe(filter(entity => entity.id === entityId)); + } + + private _pluckPrimaryKey(entity: E): string { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return entity[this._primaryKey] as string; + } + + private _getBehaviourSubject(key: string): BehaviorSubject { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this._map.get(key)!; + } +} diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 026ff9d..365ad26 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -4,3 +4,4 @@ export * from './error-message.service'; export * from './generic.service'; export * from './composite-route.guard'; export * from './stats.service'; +export * from './entities-map.service';