diff --git a/libs/rx-stateful/README.md b/libs/rx-stateful/README.md index 63fdcf1..acfb013 100644 --- a/libs/rx-stateful/README.md +++ b/libs/rx-stateful/README.md @@ -3,6 +3,8 @@ `rxStateful$` is a powerful RxJs operator that wraps any sync or async Observable and provides a stateful stream. +Hint: You can use it on both sync and async Observables. However the real benefits you will get for async Observables. + ## Installation ```bash @@ -59,6 +61,48 @@ const stateful$ = rxStateful$(from(fetch('...')), {keepValueOnRefresh: true, ref - `hasValue$` - boolean if a value is present - `context$` - the context of the stream ('suspense', 'next', 'error', 'complete') - `hasError$` - boolean if an error is present -- `error$` - the error if present +- `error$` - the error, if present + +## Configuration +`rxStateful$` provides two configuration possibilities: + +### Global configuration + +Use `provideRxStatefulConfig` to provide a global configuration for all `rxStateful$` instances. + + For a standalone application: +```typescript +import { provideRxStatefulConfig } from '@angular-kit/rx-stateful'; + +// app.component.ts +@Component({...}) +export class AppComponent{} + +// main.ts +bootstrapApplication(AppComponent, { + providers: [provideRxStatefulConfig({ keepValueOnRefresh: true })] +}); +``` +For a ngModule based application: +```typescript +import { provideRxStatefulConfig } from '@angular-kit/rx-stateful'; +// main.ts +platformBrowserDynamic() + .bootstrapModule(AppModule, { + providers: [provideRxStatefulConfig({ keepValueOnRefresh: true })] + }) + .catch((err) => console.error(err)); + +``` + +### Configuration on instance level + +You can also provide a configuration on instance level. This will also override the global configuration (if present). + +```typescript +import { rxStateful$ } from '@angular-kit/rx-stateful'; + +const rxStateful$ = rxStateful$(someSource$, { keepValueOnRefresh: true }); +``` diff --git a/libs/rx-stateful/package.json b/libs/rx-stateful/package.json index c5de657..6ff5590 100644 --- a/libs/rx-stateful/package.json +++ b/libs/rx-stateful/package.json @@ -5,8 +5,8 @@ "access": "public" }, "peerDependencies": { - "@angular/common": ">=12.2.0", - "@angular/core": ">=12.2.0", + "@angular/common": ">=14.0.0", + "@angular/core": ">=14.0.0", "rxjs": ">=7.0.0" }, "dependencies": { diff --git a/libs/rx-stateful/src/index.ts b/libs/rx-stateful/src/index.ts index a5aca19..8b2c596 100644 --- a/libs/rx-stateful/src/index.ts +++ b/libs/rx-stateful/src/index.ts @@ -7,3 +7,4 @@ export { RxStatefulConfig, } from './lib/types/types'; export { RxStatefulAccumulationFn } from './lib/types/accumulation-fn'; +export { provideRxStatefulConfig } from './lib/config/provide-config'; diff --git a/libs/rx-stateful/src/lib/config/provide-config.ts b/libs/rx-stateful/src/lib/config/provide-config.ts new file mode 100644 index 0000000..50abac8 --- /dev/null +++ b/libs/rx-stateful/src/lib/config/provide-config.ts @@ -0,0 +1,44 @@ +import { + assertInInjectionContext, + EnvironmentProviders, + inject, + InjectionToken, + makeEnvironmentProviders +} from "@angular/core"; +import {RxStatefulConfig} from "../types/types"; + +type Config = Pick, 'keepValueOnRefresh' | 'keepErrorOnRefresh' | 'accumulationFn' | 'errorMappingFn'> + +/** + * @internal + */ +const RX_STATEFUL_CONFIG = () => InjectionToken > + +/** + * @publicApi + * + * Global configuration for {@link rxStateful$}. + * + * Provide this configuration in your environment providers. + * + * @param config + */ +export function provideRxStatefulConfig(config: Config): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: RX_STATEFUL_CONFIG(), + useValue: config, + }, + ]); +}; + +/** + * @internal + * + */ +export function injectConfig() { + assertInInjectionContext(injectConfig); + + return inject(RX_STATEFUL_CONFIG(), {optional: true}) +} + diff --git a/libs/rx-stateful/src/lib/rx-stateful$.spec.ts b/libs/rx-stateful/src/lib/rx-stateful$.spec.ts index b46381b..c077353 100644 --- a/libs/rx-stateful/src/lib/rx-stateful$.spec.ts +++ b/libs/rx-stateful/src/lib/rx-stateful$.spec.ts @@ -1,17 +1,30 @@ import {mergeAll, Observable, Subject, throwError} from 'rxjs'; import {subscribeSpyTo} from '@hirez_io/observer-spy'; import {rxStateful$} from './rx-stateful$'; +import {TestBed} from "@angular/core/testing"; +import {provideRxStatefulConfig} from "./config/provide-config"; + +const test = (description :string, testFn: () => void, testBed?: TestBed) => { + it(description, () => { + (testBed ?? TestBed).runInInjectionContext(() => { + testFn(); + }) + }); +} + + + describe('rxStateful$', () => { describe('without refreshTrigger$', () => { describe('value$', () => { - it('should be lazy', () => { + test('should be lazy', () => { const source$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$).value$); expect(result.getValues().length).toEqual(0); }); - it('value$ should return the current value ', () => { + test('value$ should return the current value ', () => { const source$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$).value$); @@ -24,13 +37,13 @@ describe('rxStateful$', () => { }); describe('hasValue$', () => { - it('should return false initially', () => { + test('should return false initially', () => { const source$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$).hasValue$); expect(result.getValues()).toEqual([false]); }); - it('should return true if there is a value', () => { + test('should return true if there is a value', () => { const source$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$).hasValue$); @@ -40,7 +53,7 @@ describe('rxStateful$', () => { }); }); describe('isSuspense$', () => { - it('should return true and false', () => { + test('should return true and false', () => { const source$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$).isSuspense$); @@ -50,13 +63,13 @@ describe('rxStateful$', () => { }); }); describe('hasError$', () => { - it('should return false initially', () => { + test('should return false initially', () => { const source$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$).hasError$); expect(result.getValues()).toEqual([false]); }); - it('should return true if there is a error', () => { + test('should return true if there is a error', () => { const source$ = new Subject>(); const result = subscribeSpyTo(rxStateful$(source$.pipe(mergeAll())).hasError$); @@ -66,13 +79,13 @@ describe('rxStateful$', () => { }); }); describe('error$', () => { - it('should not emit when there is no error', () => { + test('should not emit when there is no error', () => { const source$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$).error$); expect(result.getLastValue()).toEqual(undefined); }); - it('should return the error if there is a error', () => { + test('should return the error if there is a error', () => { const source$ = new Subject>(); const result = subscribeSpyTo(rxStateful$(source$.pipe(mergeAll())).error$); @@ -82,7 +95,7 @@ describe('rxStateful$', () => { }); }); describe('context$', () => { - it('should return the correct context', () => { + test('should return the correct context', () => { const source$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$).context$); @@ -94,7 +107,7 @@ describe('rxStateful$', () => { }); describe('with refreshTrigger$', () => { describe('value$', () => { - it('should be lazy', () => { + test('should be lazy', () => { const source$ = new Subject(); const refreshTrigger$ = new Subject(); const result = subscribeSpyTo( @@ -103,7 +116,7 @@ describe('rxStateful$', () => { expect(result.getValues().length).toEqual(0); }); - it('keepValueOnRefresh: true - should return the current value when refreshTrigger$ emits', () => { + test('keepValueOnRefresh: true - should return the current value when refreshTrigger$ emits', () => { const source$ = new Subject(); const refreshTrigger$ = new Subject(); @@ -117,7 +130,7 @@ describe('rxStateful$', () => { expect(result.getValues()).toEqual([10, 10, 10]); }); - it('keepValueOnRefresh: false - should return the current value when refreshTrigger$ emits', () => { + test('keepValueOnRefresh: false - should return the current value when refreshTrigger$ emits', () => { const source$ = new Subject(); const refreshTrigger$ = new Subject(); @@ -133,7 +146,7 @@ describe('rxStateful$', () => { }); }); describe('hasValue$', () => { - it('should return false - true - true', () => { + test('should return false - true - true', () => { const source$ = new Subject(); const refreshTrigger$ = new Subject(); const result = subscribeSpyTo( @@ -145,7 +158,7 @@ describe('rxStateful$', () => { expect(result.getValues()).toEqual([false, true]); }); - it('should return false - true - false - true', () => { + test('should return false - true - false - true', () => { const source$ = new Subject(); const refreshTrigger$ = new Subject(); const result = subscribeSpyTo( @@ -159,7 +172,7 @@ describe('rxStateful$', () => { }); }); describe('isSuspense$', () => { - it('should return false - true - true', () => { + test('should return false - true - true', () => { const source$ = new Subject(); const refreshTrigger$ = new Subject(); const result = subscribeSpyTo( @@ -171,7 +184,7 @@ describe('rxStateful$', () => { expect(result.getValues()).toEqual([true, false, true, false]); }); - it('should return false - true - false - true', () => { + test('should return false - true - false - true', () => { const source$ = new Subject(); const refreshTrigger$ = new Subject(); const result = subscribeSpyTo( @@ -185,7 +198,7 @@ describe('rxStateful$', () => { }); }); describe('context$', () => { - it('should return the correct context', () => { + test('should return the correct context', () => { const source$ = new Subject(); const refreshTrigger$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$, { refreshTrigger$ }).context$); @@ -197,7 +210,7 @@ describe('rxStateful$', () => { }); }); describe('state$', () => { - it('should return the correct state', () => { + test('should return the correct state', () => { const source$ = new Subject(); const refreshTrigger$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$, { refreshTrigger$ }).state$); @@ -218,7 +231,7 @@ describe('rxStateful$', () => { }); }); describe('hasError$', () => { - it('should return false true false true', () => { + test('should return false true false true', () => { const source$ = new Subject>(); const refreshTrigger$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$.pipe(mergeAll()), { refreshTrigger$ }).hasError$); @@ -232,7 +245,7 @@ describe('rxStateful$', () => { }); describe('Configuration options', () => { describe('keepErrorOnRefresh', () => { - it('should not keep the error on refresh when option is set to false', function () { + test('should not keep the error on refresh when option is set to false', function () { const source$ = new Subject>(); const refreshTrigger$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$.pipe(mergeAll()),{ refreshTrigger$, keepErrorOnRefresh: false }).state$); @@ -249,7 +262,7 @@ describe('rxStateful$', () => { ]); }); - it('should keep the error on refresh when option is set to true', function () { + test('should keep the error on refresh when option is set to true', function () { const source$ = new Subject>(); const refreshTrigger$ = new Subject(); const result = subscribeSpyTo(rxStateful$(source$.pipe(mergeAll()),{ refreshTrigger$, keepErrorOnRefresh: true }).state$); @@ -268,4 +281,48 @@ describe('rxStateful$', () => { }); }); }); + describe('Configuration', () => { + it('should use config from provider', () => { + TestBed.configureTestingModule({ + providers: [provideRxStatefulConfig({ keepValueOnRefresh: true })], + }).runInInjectionContext(() => { + const source$ = new Subject(); + const refreshTrigger$ = new Subject(); + + const result = subscribeSpyTo( + rxStateful$(source$, { refreshTrigger$ }).value$ + ); + source$.next(10); + + refreshTrigger$.next(void 0); + refreshTrigger$.next(void 0); + + expect(result.getValues()).toEqual([10, 10, 10]); + }) + + + + }); + + it('should override config from provider', () => { + TestBed.configureTestingModule({ + providers: [provideRxStatefulConfig({ keepValueOnRefresh: true })], + }) + + TestBed.runInInjectionContext(() => { + const source$ = new Subject(); + const refreshTrigger$ = new Subject(); + + const result = subscribeSpyTo( + rxStateful$(source$, { refreshTrigger$, keepValueOnRefresh: false }).value$ + ); + source$.next(10); + + refreshTrigger$.next(void 0); + refreshTrigger$.next(void 0); + + expect(result.getValues()).toEqual([10, null, 10, null, 10]); + }) + }); + }) }); diff --git a/libs/rx-stateful/src/lib/rx-stateful$.ts b/libs/rx-stateful/src/lib/rx-stateful$.ts index ed72ba6..7e0d46c 100644 --- a/libs/rx-stateful/src/lib/rx-stateful$.ts +++ b/libs/rx-stateful/src/lib/rx-stateful$.ts @@ -1,56 +1,89 @@ import { - BehaviorSubject, - catchError, - distinctUntilChanged, - map, - merge, - NEVER, - Observable, - pipe, - ReplaySubject, - scan, - share, - skip, - startWith, - Subject, - switchMap, + BehaviorSubject, + catchError, + distinctUntilChanged, + map, + merge, + NEVER, + Observable, + pipe, + ReplaySubject, + scan, + share, + skip, + startWith, + Subject, + switchMap, } from 'rxjs'; -import {InternalRxState, RxStateful, RxStatefulConfig, RxStatefulWithError,} from './types/types'; +import {InternalRxState, RxStateful, RxStatefulConfig, RxStatefulWithError} from './types/types'; import {_handleSyncValue} from './util/handle-sync-value'; -import {defaultAccumulationFn} from "./types/accumulation-fn"; -import {createRxStateful} from "./util/create-rx-stateful"; +import {defaultAccumulationFn} from './types/accumulation-fn'; +import {createRxStateful} from './util/create-rx-stateful'; + +import {injectConfig} from './config/provide-config'; +import {inject, Injector, runInInjectionContext} from "@angular/core"; /** * @publicApi + * + * @description + * Creates a new rxStateful$ instance. + * + * rxStateful$ will enhance the source$ with additional information about the current state of the source$, like + * e.g. if it is in a suspense or error state. + * + * @example + * const source$ = httpClient.get('https://my-api.com'); + * const rxStateful$ = rxStateful$(source$); + * + * @param source$ - The source$ to enhance with additional state information. */ export function rxStateful$(source$: Observable): RxStateful; -export function rxStateful$(source$: Observable, config: RxStatefulConfig): RxStateful; -export function rxStateful$( - source$: Observable, - config?: RxStatefulConfig -): RxStateful { - const mergedConfig: RxStatefulConfig = { - keepValueOnRefresh: false, - keepErrorOnRefresh: false, - ...config, - }; +/** + * @publicApi + * + * @example + * const source$ = httpClient.get('https://my-api.com'); + * const rxStateful$ = rxStateful$(source$, { keepValueOnRefresh: true }); + * + * @param source$ - The source$ to enhance with additional state information. + * @param config - Configuration for rxStateful$. + */ +export function rxStateful$(source$: Observable, config: RxStatefulConfig): RxStateful; +export function rxStateful$(source$: Observable, config?: RxStatefulConfig): RxStateful { + const injector = config?.injector ?? inject(Injector); - const rxStateful$ = createRxStateful(createState$(source$, mergedConfig), mergedConfig); + return runInInjectionContext(injector, () => { + const environmentConfig = injectConfig(); + + const mergedConfig: RxStatefulConfig = { + keepValueOnRefresh: false, + keepErrorOnRefresh: false, + ...environmentConfig, + ...config, + }; + + const rxStateful$ = createRxStateful(createState$(source$, mergedConfig), mergedConfig); + + return rxStateful$; + }) - return rxStateful$; } -function createState$(source$: Observable, mergedConfig: RxStatefulConfig){ +function createState$(source$: Observable, mergedConfig: RxStatefulConfig) { const accumulationFn = mergedConfig.accumulationFn ?? defaultAccumulationFn; const error$$ = new Subject>(); const refresh$ = mergedConfig?.refreshTrigger$ ?? new Subject(); const { request$, refreshedRequest$ } = initSources(source$, error$$, refresh$, mergedConfig); return merge(request$, refreshedRequest$, error$$).pipe( - scan( - accumulationFn, - { isLoading: false, isRefreshing: false, value: undefined, error: undefined, context: 'suspense' } - ), + scan(accumulationFn, { + isLoading: false, + isRefreshing: false, + value: undefined, + error: undefined, + context: 'suspense', + }), distinctUntilChanged(), share({ connector: () => new ReplaySubject(1), @@ -62,13 +95,17 @@ function createState$(source$: Observable, mergedConfig: RxStatefulConfi ); } -function initSource(source$: Observable, error$$: Subject>, mergedConfig: RxStatefulConfig): Observable { +function initSource( + source$: Observable, + error$$: Subject>, + mergedConfig: RxStatefulConfig +): Observable { return source$.pipe( share({ connector: () => new ReplaySubject(1), }), catchError((error: E) => { - const errorMappingFn = mergedConfig.errorMappingFn ?? ((error: E ) => (error as any)?.message); + const errorMappingFn = mergedConfig.errorMappingFn ?? ((error: E) => (error as any)?.message); error$$.next({ error: errorMappingFn(error), context: 'error', hasError: true }); return NEVER; }) @@ -96,7 +133,7 @@ function requestSource(source$: Observable): Observable ({ value: v, isLoading: false, isRefreshing: false, context: 'next', error: undefined } as Partial< InternalRxState - >) + >) ), startWith({ isLoading: true, isRefreshing: false, context: 'suspense' } as Partial>) ); @@ -123,22 +160,20 @@ function refreshedRequestSource( (v) => ({ value: v, isLoading: false, isRefreshing: false, context: 'next', error: undefined } as Partial< InternalRxState - >) + >) ), mergedConfig?.keepValueOnRefresh ? startWith({ isLoading: true, isRefreshing: true, context: 'suspense', error: undefined } as Partial< - InternalRxState + InternalRxState >) : startWith({ - isLoading: true, - isRefreshing: true, - value: null, - context: 'suspense', - error: undefined, - } as Partial>) + isLoading: true, + isRefreshing: true, + value: null, + context: 'suspense', + error: undefined, + } as Partial>) ) ) ); } - - diff --git a/libs/rx-stateful/src/lib/types/types.ts b/libs/rx-stateful/src/lib/types/types.ts index c556374..8bfedca 100644 --- a/libs/rx-stateful/src/lib/types/types.ts +++ b/libs/rx-stateful/src/lib/types/types.ts @@ -1,5 +1,6 @@ import {Observable, Subject} from 'rxjs'; import {RxStatefulAccumulationFn} from "./accumulation-fn"; +import {Injector} from "@angular/core"; /** * @publicApi @@ -64,12 +65,24 @@ export interface InternalRxState { * rxStateful$(source$, {keepValueOnRefresh: true}) */ export interface RxStatefulConfig { + /** + * Injector to create an injection-context for rxStateful$. + */ + injector?: Injector; + /** + * Trigger to refresh the source$. + */ refreshTrigger$?: Subject; /** * Define if the value should be kept on refresh or reset to null * @default false */ keepValueOnRefresh?: boolean; + /** + * Accumulation function to accumulate the state. + * + * @default: ({ ...acc, ...val }) + */ accumulationFn?: RxStatefulAccumulationFn; /** * Define if the error should be kept on refresh or reset to null @@ -78,7 +91,7 @@ export interface RxStatefulConfig { keepErrorOnRefresh?: boolean; /** * Mapping function to map the error to a specific value. - * @param error + * @param error - the error which is thrown by the source$, e.g. a {@link HttpErrorResponse}. */ errorMappingFn?: (error: E) => any; }