Skip to content

Commit

Permalink
feat: add rxjs operators poll and whenDocumentVisible (#404)
Browse files Browse the repository at this point in the history
* feat(when-document-visible): create whenDocumentVisible rxjs operator

* test(when-document-visible): add test for the whenDocumentVisible operator

* feat(poll): create poll rxjs operator

* docs(poll): add documentation

* docs(when-document-visible): add documentation

* docs(poll): update doc

* docs: update documenation index

* update linkedin link

* apply formatting to poll operator

* test(when-document-visible): fix tests

---------

Co-authored-by: Chau Tran <nartc7789@gmail.com>
  • Loading branch information
FabienDehopre and nartc committed Jun 16, 2024
1 parent 8e7a98a commit 7ffc766
Show file tree
Hide file tree
Showing 18 changed files with 337 additions and 17 deletions.
6 changes: 6 additions & 0 deletions docs/src/content/contributors/fabiendehopre.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "Fabien Dehopré",
"twitter": "https://twitter.com/FabienDehopre",
"github": "https://github.com/FabienDehopre",
"linkedin": "https://www.linkedin.com/in/fabien1979/"
}
2 changes: 2 additions & 0 deletions docs/src/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ import { Card, CardGrid, Steps } from '@astrojs/starlight/components';
- [<strong>filterNil</strong> - filter out null and undefined values](/utilities/operators/filter-nil/)
- [<strong>mapArray</strong> - map array emitted by the stream](/utilities/operators/map-array/)
- [<strong>mapSkipUndefined</strong> - skip undefined on map](/utilities/operators/map-skip-undefined/)
- [<strong>poll</strong> - poll a stream every "period" milliseconds](/utilities/operators/poll/)
- [<strong>reduceArray</strong> - reduce array emitted by the stream](/utilities/operators/reduce-array/)
- [<strong>rxEffect</strong> - utility to create a side effect with rxjs](/utilities/operators/rx-effect/)
- [<strong>whenDocumentVisible</strong> - pause a stream when the document is hidden](/utilities/operators/when-document-visible/)
</div>
</Card>
<Card title="Code migrations" icon="document">
Expand Down
31 changes: 31 additions & 0 deletions docs/src/content/docs/utilities/Operators/poll.md
Original file line number Diff line number Diff line change
@@ -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);
```
39 changes: 39 additions & 0 deletions docs/src/content/docs/utilities/Operators/when-document-visible.md
Original file line number Diff line number Diff line change
@@ -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);
```
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,24 @@ export interface InjectDocumentVisibilityOptions {
injector?: Injector;
}

export function injectDocumentVisibilityStream(
options?: InjectDocumentVisibilityOptions,
): Observable<DocumentVisibilityState> {
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.
*
Expand All @@ -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<DocumentVisibilityState> {
const injector = assertInjector(injectDocumentVisibility, options?.injector);

return runInInjectionContext(injector, () => {
const doc: Document = options?.document ?? inject(DOCUMENT);

const docVisible$: Observable<DocumentVisibilityState> = fromEvent(
doc,
'visibilitychange',
).pipe(
startWith(doc.visibilityState),
map(() => doc.visibilityState),
);

return toSignal<DocumentVisibilityState>(docVisible$, {
requireSync: true,
});
const docVisible$: Observable<DocumentVisibilityState> =
injectDocumentVisibilityStream(options);
return toSignal<DocumentVisibilityState>(docVisible$, {
requireSync: true,
});
}
3 changes: 3 additions & 0 deletions libs/ngxtension/poll/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ngxtension/poll

Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/poll`.
5 changes: 5 additions & 0 deletions libs/ngxtension/poll/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
27 changes: 27 additions & 0 deletions libs/ngxtension/poll/project.json
Original file line number Diff line number Diff line change
@@ -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}"]
}
}
}
1 change: 1 addition & 0 deletions libs/ngxtension/poll/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { poll } from './poll';
41 changes: 41 additions & 0 deletions libs/ngxtension/poll/src/poll.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
}));
});
23 changes: 23 additions & 0 deletions libs/ngxtension/poll/src/poll.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
period: number,
initialDelay = 0,
): MonoTypeOperatorFunction<T> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return (source: Observable<T>) => {
return timer(initialDelay, period).pipe(concatMap(() => source));
};
}
3 changes: 3 additions & 0 deletions libs/ngxtension/when-document-visible/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ngxtension/when-document-visible

Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/when-document-visible`.
5 changes: 5 additions & 0 deletions libs/ngxtension/when-document-visible/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
27 changes: 27 additions & 0 deletions libs/ngxtension/when-document-visible/project.json
Original file line number Diff line number Diff line change
@@ -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}"]
}
}
}
1 change: 1 addition & 0 deletions libs/ngxtension/when-document-visible/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const greeting = 'Hello World!';
Original file line number Diff line number Diff line change
@@ -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<number>): 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 })));
});
}));
});
});
36 changes: 36 additions & 0 deletions libs/ngxtension/when-document-visible/src/when-document-visible.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
options?: InjectDocumentVisibilityOptions,
): MonoTypeOperatorFunction<T> {
const visibilityChanged$ = injectDocumentVisibilityStream(options);
const [pageVisible$, pageHidden$] = partition(visibilityChanged$, () => {
return document.visibilityState === 'visible';
});
return (source: Observable<T>) => {
return source.pipe(
takeUntil(pageHidden$),
repeat({ delay: () => pageVisible$ }),
);
};
}
Loading

0 comments on commit 7ffc766

Please sign in to comment.