diff --git a/docs/src/content/contributors/fabiendehopre.json b/docs/src/content/contributors/fabiendehopre.json new file mode 100644 index 00000000..497a6ef1 --- /dev/null +++ b/docs/src/content/contributors/fabiendehopre.json @@ -0,0 +1,6 @@ +{ + "name": "Fabien Dehopré", + "twitter": "https://twitter.com/FabienDehopre", + "github": "https://github.com/FabienDehopre", + "linkedin": "https://www.linkedin.com/in/fabien1979/" +} diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 1b4c657c..abe61f31 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -65,8 +65,10 @@ import { Card, CardGrid, Steps } from '@astrojs/starlight/components'; - [filterNil - filter out null and undefined values](/utilities/operators/filter-nil/) - [mapArray - map array emitted by the stream](/utilities/operators/map-array/) - [mapSkipUndefined - skip undefined on map](/utilities/operators/map-skip-undefined/) + - [poll - poll a stream every "period" milliseconds](/utilities/operators/poll/) - [reduceArray - reduce array emitted by the stream](/utilities/operators/reduce-array/) - [rxEffect - utility to create a side effect with rxjs](/utilities/operators/rx-effect/) + - [whenDocumentVisible - pause a stream when the document is hidden](/utilities/operators/when-document-visible/) diff --git a/docs/src/content/docs/utilities/Operators/poll.md b/docs/src/content/docs/utilities/Operators/poll.md new file mode 100644 index 00000000..b1ccebcb --- /dev/null +++ b/docs/src/content/docs/utilities/Operators/poll.md @@ -0,0 +1,31 @@ +--- +title: poll +description: RxJS operator to apply to a stream that you want to poll every "period" milliseconds after an optional "initialDelay" milliseconds. +entryPoint: poll +badge: stable +contributors: ['fabiendehopre'] +--- + +## Import + +```typescript +import { poll } from 'ngxtension/poll'; +``` + +## Usage + +You can use this operator to poll and API at a fixed interval with an optional initial delay. + +```typescript +import { HttpClient } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { poll } from 'ngxtension/poll'; + +const httpClient = inject(HttpClient); +httpClient + .get('https://api.example.com/data') + .pipe( + poll(10000, 5000), // poll every 10s after 5s + ) + .subscribe(console.log); +``` diff --git a/docs/src/content/docs/utilities/Operators/when-document-visible.md b/docs/src/content/docs/utilities/Operators/when-document-visible.md new file mode 100644 index 00000000..ca340930 --- /dev/null +++ b/docs/src/content/docs/utilities/Operators/when-document-visible.md @@ -0,0 +1,39 @@ +--- +title: whenDocumentVisible +description: RxJS operator to pause a stream when the document is hidden and to resume the stream when the document is visible. +entryPoint: when-document-visible +badge: stable +contributors: ['fabiendehopre'] +--- + +## Import + +```typescript +import { whenDocumentVisible } from 'ngxtension/when-document-visible'; +``` + +## Usage + +You can use it to pause a stream when the document is hidden and to resume the stream when the document is visible. + +It uses the same options as the `injectDocumentVisiblity` function. + +A good use case is to pause an API polling when the user switches to another tab or another application. + +```typescript +import { DOCUMENT } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { poll } from 'ngxtension/poll'; +import { whenDocumentVisible } from 'ngxtension/when-document-visible'; + +const httpClient = inject(HttpClient); +const document = inject(DOCUMENT); +httpClient + .get('https://api.example.com/data') + .pipe( + poll(10000, 5000), // poll every 10s after 5s + whenDocumentVisible({ document }), + ) + .subscribe(console.log); +``` diff --git a/libs/ngxtension/inject-document-visibility/src/inject-document-visibility.ts b/libs/ngxtension/inject-document-visibility/src/inject-document-visibility.ts index 81859f03..856503c8 100644 --- a/libs/ngxtension/inject-document-visibility/src/inject-document-visibility.ts +++ b/libs/ngxtension/inject-document-visibility/src/inject-document-visibility.ts @@ -21,6 +21,24 @@ export interface InjectDocumentVisibilityOptions { injector?: Injector; } +export function injectDocumentVisibilityStream( + options?: InjectDocumentVisibilityOptions, +): Observable { + const injector = assertInjector( + injectDocumentVisibilityStream, + options?.injector, + ); + + return runInInjectionContext(injector, () => { + const doc: Document = options?.document ?? inject(DOCUMENT); + + return fromEvent(doc, 'visibilitychange').pipe( + startWith(doc.visibilityState), + map(() => doc.visibilityState), + ); + }); +} + /** * Injects and monitors the current document visibility state. Emits the state initially and then emits on every change. * @@ -40,25 +58,12 @@ export interface InjectDocumentVisibilityOptions { * * @returns A signal that emits the current `DocumentVisibilityState` (`"visible"`, `"hidden"`, etc.) initially and whenever the document visibility state changes. */ - export function injectDocumentVisibility( options?: InjectDocumentVisibilityOptions, ): Signal { - const injector = assertInjector(injectDocumentVisibility, options?.injector); - - return runInInjectionContext(injector, () => { - const doc: Document = options?.document ?? inject(DOCUMENT); - - const docVisible$: Observable = fromEvent( - doc, - 'visibilitychange', - ).pipe( - startWith(doc.visibilityState), - map(() => doc.visibilityState), - ); - - return toSignal(docVisible$, { - requireSync: true, - }); + const docVisible$: Observable = + injectDocumentVisibilityStream(options); + return toSignal(docVisible$, { + requireSync: true, }); } diff --git a/libs/ngxtension/poll/README.md b/libs/ngxtension/poll/README.md new file mode 100644 index 00000000..0ae0eaa2 --- /dev/null +++ b/libs/ngxtension/poll/README.md @@ -0,0 +1,3 @@ +# ngxtension/poll + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/poll`. diff --git a/libs/ngxtension/poll/ng-package.json b/libs/ngxtension/poll/ng-package.json new file mode 100644 index 00000000..b3e53d69 --- /dev/null +++ b/libs/ngxtension/poll/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/poll/project.json b/libs/ngxtension/poll/project.json new file mode 100644 index 00000000..b85cfb51 --- /dev/null +++ b/libs/ngxtension/poll/project.json @@ -0,0 +1,27 @@ +{ + "name": "ngxtension/poll", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ngxtension/poll/src", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["poll"], + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/libs/ngxtension/poll/src/index.ts b/libs/ngxtension/poll/src/index.ts new file mode 100644 index 00000000..3504df2e --- /dev/null +++ b/libs/ngxtension/poll/src/index.ts @@ -0,0 +1 @@ +export { poll } from './poll'; diff --git a/libs/ngxtension/poll/src/poll.spec.ts b/libs/ngxtension/poll/src/poll.spec.ts new file mode 100644 index 00000000..f29e6f32 --- /dev/null +++ b/libs/ngxtension/poll/src/poll.spec.ts @@ -0,0 +1,41 @@ +import { fakeAsync, tick } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { poll } from './poll'; + +describe(poll.name, () => { + it('should return an observable that polls every 1000ms', fakeAsync(() => { + const callback = jest.fn(); + const source = of('test'); + const result = source.pipe(poll(1000)); + const sub = result.subscribe(() => { + callback(); + }); + expect(callback).toHaveBeenCalledTimes(0); + tick(1000); + expect(callback).toHaveBeenCalledTimes(2); + tick(1000); + expect(callback).toHaveBeenCalledTimes(3); + sub.unsubscribe(); + tick(1000); + expect(callback).toHaveBeenCalledTimes(3); + })); + + it('should return an observable that polls every 1000ms after 500ms', fakeAsync(() => { + const callback = jest.fn(); + const source = of('test'); + const result = source.pipe(poll(1000, 500)); + const sub = result.subscribe(() => { + callback(); + }); + expect(callback).toHaveBeenCalledTimes(0); + tick(500); + expect(callback).toHaveBeenCalledTimes(1); + tick(1000); + expect(callback).toHaveBeenCalledTimes(2); + tick(1000); + expect(callback).toHaveBeenCalledTimes(3); + sub.unsubscribe(); + tick(1000); + expect(callback).toHaveBeenCalledTimes(3); + })); +}); diff --git a/libs/ngxtension/poll/src/poll.ts b/libs/ngxtension/poll/src/poll.ts new file mode 100644 index 00000000..0e679bec --- /dev/null +++ b/libs/ngxtension/poll/src/poll.ts @@ -0,0 +1,23 @@ +import { concatMap, MonoTypeOperatorFunction, Observable, timer } from 'rxjs'; +// source: https://netbasal.com/use-rxjs-to-modify-app-behavior-based-on-page-visibility-ce499c522be4 + +/** + * RxJS operator to apply to a stream that you want to poll every "period" milliseconds after an optional "initialDelay" milliseconds. + * + * @param period - Indicates the delay between 2 polls. + * @param initialDelay - Indicates the delay before the first poll occurs. + * @example This example shows how to do an HTTP GET every 5 seconds: + * ```ts + * http.get('http://api').pipe(poll(5000)).subscribe(result => {}); + * ``` + * @public + */ +export function poll( + period: number, + initialDelay = 0, +): MonoTypeOperatorFunction { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return (source: Observable) => { + return timer(initialDelay, period).pipe(concatMap(() => source)); + }; +} diff --git a/libs/ngxtension/when-document-visible/README.md b/libs/ngxtension/when-document-visible/README.md new file mode 100644 index 00000000..94a4534b --- /dev/null +++ b/libs/ngxtension/when-document-visible/README.md @@ -0,0 +1,3 @@ +# ngxtension/when-document-visible + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/when-document-visible`. diff --git a/libs/ngxtension/when-document-visible/ng-package.json b/libs/ngxtension/when-document-visible/ng-package.json new file mode 100644 index 00000000..b3e53d69 --- /dev/null +++ b/libs/ngxtension/when-document-visible/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/when-document-visible/project.json b/libs/ngxtension/when-document-visible/project.json new file mode 100644 index 00000000..38623475 --- /dev/null +++ b/libs/ngxtension/when-document-visible/project.json @@ -0,0 +1,27 @@ +{ + "name": "ngxtension/when-document-visible", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ngxtension/when-document-visible/src", + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/ngxtension/jest.config.ts", + "testPathPattern": ["when-document-visible"], + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + } + } +} diff --git a/libs/ngxtension/when-document-visible/src/index.ts b/libs/ngxtension/when-document-visible/src/index.ts new file mode 100644 index 00000000..59d806d6 --- /dev/null +++ b/libs/ngxtension/when-document-visible/src/index.ts @@ -0,0 +1 @@ +export const greeting = 'Hello World!'; diff --git a/libs/ngxtension/when-document-visible/src/when-document-visible.spec.ts b/libs/ngxtension/when-document-visible/src/when-document-visible.spec.ts new file mode 100644 index 00000000..b4cb79b4 --- /dev/null +++ b/libs/ngxtension/when-document-visible/src/when-document-visible.spec.ts @@ -0,0 +1,61 @@ +import { Injector } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Observable, timer } from 'rxjs'; +import { whenDocumentVisible } from './when-document-visible'; + +describe(whenDocumentVisible.name, () => { + function triggerVisibilityChange(newState: DocumentVisibilityState) { + // Change the visibility state + Object.defineProperty(document, 'visibilityState', { + writable: true, + configurable: true, + value: newState, + }); + + // Dispatch the event + const event = new Event('visibilitychange'); + document.dispatchEvent(event); + } + + describe('should emit values only when document is visible', () => { + const runTest = (source: Observable): void => { + const callback = jest.fn(); + triggerVisibilityChange('visible'); + const sub = source.subscribe(callback); + tick(); // trigger initial emission + expect(callback).toHaveBeenCalledTimes(1); + tick(10_000); // will emit 10 times in 10 seconds + expect(callback).toHaveBeenCalledTimes(11); + triggerVisibilityChange('hidden'); + tick(10_000); + expect(callback).toHaveBeenCalledTimes(11); + triggerVisibilityChange('visible'); + tick(10_000); // will emit 11 times (once initially and 10 times in 10 seconds) + expect(callback).toHaveBeenCalledTimes(22); + sub.unsubscribe(); + }; + + it('should emit values only when document is visible using provided injector', fakeAsync(() => { + const source = timer(0, 1000); + runTest( + source.pipe( + whenDocumentVisible({ injector: TestBed.inject(Injector) }), + ), + ); + })); + + it('should emit values only when document is visible in injector context', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const source = timer(0, 1000); + runTest(source.pipe(whenDocumentVisible())); + }); + })); + + it('should emit values only when document is visible using provided document', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const source = timer(0, 1000); + runTest(source.pipe(whenDocumentVisible({ document }))); + }); + })); + }); +}); diff --git a/libs/ngxtension/when-document-visible/src/when-document-visible.ts b/libs/ngxtension/when-document-visible/src/when-document-visible.ts new file mode 100644 index 00000000..314392fa --- /dev/null +++ b/libs/ngxtension/when-document-visible/src/when-document-visible.ts @@ -0,0 +1,36 @@ +import { + InjectDocumentVisibilityOptions, + injectDocumentVisibilityStream, +} from 'ngxtension/inject-document-visibility'; +import { + MonoTypeOperatorFunction, + Observable, + partition, + repeat, + takeUntil, +} from 'rxjs'; + +/** + * RxJS operator to pause a stream when the document is hidden (i.e.: tab is not active) + * and to resume the stream when the document is visible (i.e.: tab is active). + * + * @example This example shows how to do an HTTP GET every 5 seconds only when the page is visible in the browser: + * ```typescript + * http.get('http://api').pipe(poll(5000), whenPageVisible()).subscribe(result => {}) + * ``` + * @public + */ +export function whenDocumentVisible( + options?: InjectDocumentVisibilityOptions, +): MonoTypeOperatorFunction { + const visibilityChanged$ = injectDocumentVisibilityStream(options); + const [pageVisible$, pageHidden$] = partition(visibilityChanged$, () => { + return document.visibilityState === 'visible'; + }); + return (source: Observable) => { + return source.pipe( + takeUntil(pageHidden$), + repeat({ delay: () => pageVisible$ }), + ); + }; +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 8d9f4f8e..53c18c5c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -110,6 +110,7 @@ "ngxtension/navigation-end": [ "libs/ngxtension/navigation-end/src/index.ts" ], + "ngxtension/poll": ["libs/ngxtension/poll/src/index.ts"], "ngxtension/repeat": ["libs/ngxtension/repeat/src/index.ts"], "ngxtension/repeat-pipe": ["libs/ngxtension/repeat-pipe/src/index.ts"], "ngxtension/resize": ["libs/ngxtension/resize/src/index.ts"], @@ -128,6 +129,9 @@ "ngxtension/trackby-id-prop": [ "libs/ngxtension/trackby-id-prop/src/index.ts" ], + "ngxtension/when-document-visible": [ + "libs/ngxtension/when-document-visible/src/index.ts" + ], "plugin": ["libs/plugin/src/index.ts"] } },