diff --git a/projects/natural/src/lib/classes/abstract-detail.ts b/projects/natural/src/lib/classes/abstract-detail.ts index 21a29b55..e938d9c9 100644 --- a/projects/natural/src/lib/classes/abstract-detail.ts +++ b/projects/natural/src/lib/classes/abstract-detail.ts @@ -93,7 +93,6 @@ export class NaturalAbstractDetail< } // Subscribe to model to know when Apollo cache is changed, so we can reflect it into `data.model` - let firstTime = true; this.#modelSub?.unsubscribe(); this.#modelSub = incomingData.model .pipe(takeUntil(this.ngUnsubscribe)) @@ -102,16 +101,8 @@ export class NaturalAbstractDetail< ...incomingData, model: model, } as Data; - // Initialize the form exactly once, and never again when the model is updated in Apollo cache - if (firstTime) { - firstTime = false; - console.log('firstTime', this.data.model); - } }); - console.log('initForm', this.data.model); this.initForm(); - - console.log('FINISHED'); }); } else { this.initForm(); diff --git a/projects/natural/src/lib/classes/rxjs.spec.ts b/projects/natural/src/lib/classes/rxjs.spec.ts index daf6039d..7c45e93a 100644 --- a/projects/natural/src/lib/classes/rxjs.spec.ts +++ b/projects/natural/src/lib/classes/rxjs.spec.ts @@ -1,5 +1,5 @@ -import {cancellableTimeout, debug, emptyResult, spyObservable, waitUntilFirstEmission} from './rxjs'; -import {first, Observable, of, ReplaySubject, Subject} from 'rxjs'; +import {cancellableTimeout} from './rxjs'; +import {ReplaySubject, Subject} from 'rxjs'; import {fakeAsync, tick} from '@angular/core/testing'; describe('cancellableTimeout', () => { @@ -65,258 +65,3 @@ describe('cancellableTimeout', () => { expect(completed).toBe(true); })); }); - -describe('waitUntilFirstEmission', () => { - let source$: Observable; - let sourceSubject: Subject<1 | 2 | 3>; - - beforeEach(() => { - console.error('______________________________'); - sourceSubject = new Subject<1 | 2 | 3>(); - source$ = sourceSubject.pipe(); // For debugging it's convenient to drop `debug('source)` in there - }); - - it('observable is cold, nothing happen without subscription', () => { - const source = spyObservable(source$); - waitUntilFirstEmission(source.observable); - - sourceSubject.next(1); - sourceSubject.next(2); - sourceSubject.next(3); - - expect(source.result).toEqual(emptyResult); - }); - - it('first subscriber will receive exactly 1 emission and be completed automatically, but source is still subscribed', () => { - const source = spyObservable(source$); - const obs = waitUntilFirstEmission(source.observable); - const firstSubscriber = spyObservable(obs); - - firstSubscriber.observable.subscribe(); - - sourceSubject.next(1); - sourceSubject.next(2); - sourceSubject.next(3); - - expect(source.result).toEqual({ - called: 3, - completed: 0, - errored: 0, - subscribed: 1, - unsubscribed: 0, - }); - - expect(firstSubscriber.result).toEqual({ - called: 1, - completed: 1, - errored: 0, - subscribed: 1, - unsubscribed: 0, - }); - }); - - fit('after first subscriber completes, second subscriber will receive first emission (replayed) and all others', done => { - // of(1,2,3,4).pipe(debug('QQQQQQQQQQQ'), first(),debug('QQQ'),).subscribe(); - const source = spyObservable(source$.pipe(debug('source'))); - const obs = waitUntilFirstEmission(source.observable.pipe(debug('resolver'), first())); - const firstSubscriber = spyObservable(obs); - - firstSubscriber.observable.subscribe({ - next: model => { - const secondSubscriber = spyObservable(model); - secondSubscriber.observable.pipe(debug('component')).subscribe({ - next: value => { - switch (secondSubscriber.result.called) { - case 1: - expect(value).toBe(1); - break; - case 2: - expect(value).toBe(2); - sourceSubject.next(3); - break; - case 3: - expect(value).toBe(3); - sourceSubject.complete(); - break; - default: - throw new Error(`should not be called ${secondSubscriber.result.called} times`); - } - }, - complete: () => { - expect(source.result) - .withContext('when second subscriber is completed, that means our source was completed') - .toEqual({ - called: 3, - completed: 1, - errored: 0, - subscribed: 1, - unsubscribed: 0, - }); - - expect(secondSubscriber.result) - .withContext('second subscriber is completed and it received all emission from source') - .toEqual({ - called: 3, - completed: 1, - errored: 0, - subscribed: 1, - unsubscribed: 0, - }); - - done(); - }, - }); - - expect(secondSubscriber.result) - .withContext('second subscriber immediately receives the first emission as a replay') - .toEqual({ - called: 1, - completed: 0, - errored: 0, - subscribed: 1, - unsubscribed: 0, - }); - }, - }); - - sourceSubject.next(1); - sourceSubject.next(2); - - expect(firstSubscriber.result).toEqual({ - called: 1, - completed: 1, - errored: 0, - subscribed: 1, - unsubscribed: 0, - }); - }); - - it('idem but with `of()`', done => { - const source = spyObservable(of(1, 2, 3).pipe(debug('source'))); - const obs = waitUntilFirstEmission(source.observable.pipe(debug('resolver'), first())); - const firstSubscriber = spyObservable(obs); - - firstSubscriber.observable.subscribe({ - next: model => { - const secondSubscriber = spyObservable(model); - secondSubscriber.observable.pipe(debug('component')).subscribe({ - next: value => { - switch (secondSubscriber.result.called) { - case 1: - expect(value).toBe(1); - break; - default: - throw new Error(`should not be called ${secondSubscriber.result.called} times`); - } - }, - complete: () => { - expect(source.result) - .withContext('when second subscriber is completed, that means our source was completed') - .toEqual({ - called: 1, - completed: 0, - errored: 0, - subscribed: 1, - unsubscribed: 0, - }); - - expect(secondSubscriber.result) - .withContext('second subscriber is completed and it received all emission from source') - .toEqual({ - called: 1, - completed: 1, - errored: 0, - subscribed: 1, - unsubscribed: 0, - }); - console.log('SUCESS'); - done(); - }, - }); - - expect(secondSubscriber.result) - .withContext('second subscriber immediately receives the first emission as a replay') - .toEqual({ - called: 1, - completed: 0, - errored: 0, - subscribed: 1, - unsubscribed: 0, - }); - }, - }); - - expect(firstSubscriber.result).toEqual({ - called: 1, - completed: 1, - errored: 0, - subscribed: 1, - unsubscribed: 0, - }); - }); - - it('if first subscriber unsubscribes (before first emission), source is unsubscribed', () => { - const source = spyObservable(source$); - const obs = waitUntilFirstEmission(source.observable); - const firstSubscriber = spyObservable(obs); - - const subscription = firstSubscriber.observable.subscribe(); - subscription.unsubscribe(); - - expect(source.result).toEqual({ - called: 0, - completed: 0, - errored: 0, - subscribed: 1, - unsubscribed: 1, - }); - - expect(firstSubscriber.result).toEqual({ - called: 0, - completed: 0, - errored: 0, - subscribed: 1, - unsubscribed: 1, - }); - }); - - it('if second subscriber unsubscribes, source is unsubscribed', fakeAsync(() => { - const source = spyObservable(source$); - const obs = waitUntilFirstEmission(source.observable); - const firstSubscriber = spyObservable(obs); - - firstSubscriber.observable.subscribe({ - next: model => { - const secondSubscriber = spyObservable(model); - const subscription = secondSubscriber.observable.subscribe(); - subscription.unsubscribe(); - - expect(secondSubscriber.result).toEqual({ - called: 1, - completed: 0, - errored: 0, - subscribed: 1, - unsubscribed: 1, - }); - }, - }); - - sourceSubject.next(1); - - expect(source.result).toEqual({ - called: 1, - completed: 0, - errored: 0, - subscribed: 1, - unsubscribed: 1, - }); - - expect(firstSubscriber.result).toEqual({ - called: 1, - completed: 1, - errored: 0, - subscribed: 1, - unsubscribed: 0, - }); - })); -}); diff --git a/projects/natural/src/lib/classes/rxjs.ts b/projects/natural/src/lib/classes/rxjs.ts index 1796ba96..adbc0cdc 100644 --- a/projects/natural/src/lib/classes/rxjs.ts +++ b/projects/natural/src/lib/classes/rxjs.ts @@ -1,4 +1,4 @@ -import {map, MonoTypeOperatorFunction, Observable, Subscriber, Subscription, take, takeUntil, tap, timer} from 'rxjs'; +import {map, MonoTypeOperatorFunction, Observable, take, takeUntil, tap, timer} from 'rxjs'; /** * Behave like setTimeout(), but with a mandatory cancel mechanism. @@ -51,141 +51,3 @@ export function debug(debugName: string): MonoTypeOperatorFunction { complete: () => console.log('COMPLETE', debugName), }); } - -type SpyResult = { - called: number; - completed: number; - errored: number; - subscribed: number; - unsubscribed: number; -}; - -type ObservableSpy = { - result: SpyResult; - observable: Observable; -}; - -export const emptyResult: Readonly = { - called: 0, - completed: 0, - errored: 0, - subscribed: 0, - unsubscribed: 0, -} as const; - -export function spyObservable(observable: Observable): ObservableSpy { - const result = { - called: 0, - completed: 0, - errored: 0, - subscribed: 0, - unsubscribed: 0, - }; - return { - result: result, - observable: observable.pipe( - tap({ - next: () => result.called++, - complete: () => result.completed++, - error: () => result.errored++, - subscribe: () => result.subscribed++, - unsubscribe: () => result.unsubscribed++, - }), - ), - }; -} - -/** - * This takes a source observable and will return a higher order observable that will emit when the source observable - * emitted for the first time. The value emitted is an observable that forward emissions from the source observable, - * including the original first emission. - * - * The first subscriber will automatically complete after the first emission. All other subsequent subscribers will - * receive the same first value (replayed), and all subsequent values. And they must unsubscribe. - */ -export function waitUntilFirstEmission(source: Observable): Observable> { - let sourceSubscription: Subscription | null = null; - let lastResult: {value: T} | null = null; - const subscribers = new Map, boolean>(); - let isFirstSubscription = true; - let firstSubscriptionIsNexting = false; - let subscriptionCountEver = 0; - - const result = new Observable(subscriber => { - subscriptionCountEver++; - subscribers.set(subscriber, isFirstSubscription); - isFirstSubscription = false; - - if (lastResult) { - console.log('will replay', lastResult); - subscriber.next(lastResult.value); - } - - if (subscribers.get(subscriber)) { - sourceSubscription = source.subscribe({ - next: value => { - lastResult = {value}; - - for (const s of Array.from(subscribers.keys())) { - const isFirst = subscribers.get(s); - if (isFirst) { - firstSubscriptionIsNexting = true; - } - - s.next(value); - - // The first subscriber will complete after the first emission - if (subscribers.get(s)) { - s.complete(); - } - - if (isFirst) { - firstSubscriptionIsNexting = false; - } - } - }, - error: error => { - for (const s of subscribers.keys()) { - s.error(error); - } - }, - complete: () => { - console.log('forward completing'); - console.log('forward completing'); - for (const s of subscribers.keys()) { - s.complete(); - } - }, - }); - } - - return () => { - const wasFirstSubscription = subscribers.get(subscriber); - subscribers.delete(subscriber); - /** - * If we were the last subscriber, then we might unsubscribe the source, if one of the following is true: - * - * - this is the second (or more) subscriber (not the first one which is special) - * - this is the first subscriber, and I have been unsubscribed by outside (typically by Angular cancelled navigation), not by the auto-completion mechanism - * - in the entire lifetime of this observable there's been at least two subscribers - */ - console.log('maybe unsubscribe ?'); - if ( - subscribers.size === 0 && - (!wasFirstSubscription || !firstSubscriptionIsNexting || subscriptionCountEver > 1) - ) { - console.warn('will unsubscribe', { - wasFirstSubscription, - firstSubscriptionIsNexting, - subscriptionCountEver, - }); - sourceSubscription?.unsubscribe(); - sourceSubscription = null; - isFirstSubscription = false; - // lastResult = null; - } - }; - }); - - return result.pipe(map(() => result)); -} diff --git a/projects/natural/src/lib/services/abstract-model.service.ts b/projects/natural/src/lib/services/abstract-model.service.ts index 28a09043..d1e78ce2 100644 --- a/projects/natural/src/lib/services/abstract-model.service.ts +++ b/projects/natural/src/lib/services/abstract-model.service.ts @@ -11,7 +11,6 @@ import {makePlural, mergeOverrideArray, relationsToIds, upperCaseFirstLetter} fr import {PaginatedData} from '../classes/data-source'; import {NaturalDebounceService} from './debounce.service'; import {ApolloQueryResult} from '@apollo/client/core/types'; -import {debug} from '../classes/rxjs'; export interface FormValidators { [key: string]: ValidatorFn[]; @@ -469,15 +468,18 @@ export abstract class NaturalAbstractModelService< } /** - * Resolve model, if the id is provided, or else return default values, in order to show a form + * If the id is provided, resolves an observable model. The observable model will only be emitted after we are sure + * that Apollo cache is fresh and warm. Then the component can subscribe to the observable model to get the model + * immediately from Apollo cache and any subsequents future mutations that may happen to Apollo cache. + * + * Without id, returns default values, in order to show a creation form. */ - public resolve(id: string): Observable> { + public resolve(id: string | undefined): Observable> { if (id) { - const onlyNetwork = this.watchOne('123', 'network-only').pipe(first(), debug('ZZZresolver')); - const onlyCache = this.watchOne('123', 'cache-first').pipe(debug('ZZZcomponent')); + const onlyNetwork = this.watchOne(id, 'network-only').pipe(first()); + const onlyCache = this.watchOne(id, 'cache-first'); return onlyNetwork.pipe(map(() => onlyCache)); - // return this.getOne(id); } else { return of(of(this.getDefaultForServer() as Tone)); } diff --git a/projects/natural/src/lib/services/debounce.service.spec.ts b/projects/natural/src/lib/services/debounce.service.spec.ts index ff3ca6d8..b65349fd 100644 --- a/projects/natural/src/lib/services/debounce.service.spec.ts +++ b/projects/natural/src/lib/services/debounce.service.spec.ts @@ -1,8 +1,50 @@ import {fakeAsync, TestBed, tick} from '@angular/core/testing'; import {TestScheduler} from 'rxjs/testing'; -import {of, throwError} from 'rxjs'; +import {Observable, of, tap, throwError} from 'rxjs'; import {NaturalDebounceService} from './debounce.service'; -import {emptyResult, spyObservable} from '../classes/rxjs'; + +type SpyResult = { + called: number; + completed: number; + errored: number; + subscribed: number; + unsubscribed: number; +}; + +type ObservableSpy = { + result: SpyResult; + observable: Observable; +}; + +const emptyResult: Readonly = { + called: 0, + completed: 0, + errored: 0, + subscribed: 0, + unsubscribed: 0, +} as const; + +function spyObservable(observable: Observable): ObservableSpy { + const result = { + called: 0, + completed: 0, + errored: 0, + subscribed: 0, + unsubscribed: 0, + }; + return { + result: result, + observable: observable.pipe( + tap({ + next: () => result.called++, + complete: () => result.completed++, + error: () => result.errored++, + subscribe: () => result.subscribed++, + unsubscribe: () => result.unsubscribed++, + }), + ), + }; +} describe('NaturalDebounceService', () => { let scheduler: TestScheduler; diff --git a/projects/natural/src/lib/testing/item.resolver.ts b/projects/natural/src/lib/testing/item.resolver.ts index 95ab582d..2f2cda97 100644 --- a/projects/natural/src/lib/testing/item.resolver.ts +++ b/projects/natural/src/lib/testing/item.resolver.ts @@ -1,33 +1,13 @@ -import {concat, first, map, Observable, of, Subject} from 'rxjs'; +import {Observable} from 'rxjs'; import {Item, ItemInput, ItemService} from './item.service'; import {inject} from '@angular/core'; -import {debug, waitUntilFirstEmission} from '@ecodev/natural'; /** * Resolve Item data for router and panels service */ -export function resolveItemSyncrhonous(): Observable> { +export function resolveItem(): Observable> { const itemService = inject(ItemService); console.warn('resolve Item'); - return itemService.resolve('1'); - const onlyNetwork = itemService.watchOne('123', 'network-only').pipe(first()); - const onlyCache = itemService.watchOne('123', 'cache-first'); - - return onlyNetwork.pipe(map(() => onlyCache)); - return waitUntilFirstEmission(concat(of(1 as any, 2 as any, 3 as any)).pipe(debug('source'))); + return itemService.resolve(''); } - -export function resolveDelayedItem(): Observable> { - const itemService = inject(ItemService); - console.warn('resolve delayed Item'); - - let apollo = new Subject(); - setTimeout(() => apollo.next(itemService.getItem()), 2000); - setTimeout(() => apollo.next(itemService.getItem(true)), 4000); - const source = apollo.pipe(debug('source')); - - return waitUntilFirstEmission(source); -} - -export const resolveItem = resolveItemSyncrhonous; diff --git a/projects/natural/src/lib/testing/item.service.ts b/projects/natural/src/lib/testing/item.service.ts index 1e808038..0500da90 100644 --- a/projects/natural/src/lib/testing/item.service.ts +++ b/projects/natural/src/lib/testing/item.service.ts @@ -103,7 +103,7 @@ export class ItemService extends NaturalAbstractModelService< } public override getOne(id: string): Observable { - return of(); + return of(this.getItem(true, 2, id)); } public override watchOne(id: string, fetchPolicy: WatchQueryFetchPolicy = 'cache-and-network'): Observable { diff --git a/projects/natural/src/public-api.ts b/projects/natural/src/public-api.ts index ada6c772..d3c146bb 100644 --- a/projects/natural/src/public-api.ts +++ b/projects/natural/src/public-api.ts @@ -13,7 +13,7 @@ export * from './lib/classes/abstract-navigable-list'; export {createHttpLink} from './lib/classes/apollo-utils'; export * from './lib/classes/data-source'; export * from './lib/classes/query-variable-manager'; -export {cancellableTimeout, debug, waitUntilFirstEmission} from './lib/classes/rxjs'; +export {cancellableTimeout, debug} from './lib/classes/rxjs'; export * from './lib/classes/utility'; export * from './lib/classes/validators'; export {validTlds} from './lib/classes/tld'; diff --git a/src/app/detail/detail.component.html b/src/app/detail/detail.component.html index e1172df6..bfaa0686 100644 --- a/src/app/detail/detail.component.html +++ b/src/app/detail/detail.component.html @@ -1,6 +1,5 @@

NaturalAbstractDetail

-
{{ data | json }}