Skip to content

Commit

Permalink
feat: add deriveLoading RxJs-Operator (#439)
Browse files Browse the repository at this point in the history
* feat: add deriveLoading RxJs-Operator

* docs: add inline docs for deriveLoading RxJs-Operator

---------

Co-authored-by: Chau Tran <[email protected]>
  • Loading branch information
mikelgo and nartc authored Jul 15, 2024
1 parent 2ba97f9 commit 559a89a
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 0 deletions.
3 changes: 3 additions & 0 deletions libs/ngxtension/derive-loading/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ngxtension/derive-loading

Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/derive-loading`.
5 changes: 5 additions & 0 deletions libs/ngxtension/derive-loading/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/derive-loading/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "ngxtension/derive-loading",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/ngxtension/derive-loading/src",
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/ngxtension/jest.config.ts",
"testPathPattern": ["derive-loading"],
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
}
}
}
58 changes: 58 additions & 0 deletions libs/ngxtension/derive-loading/src/derive-loading.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { of, timer } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { deriveLoading } from './derive-loading';

describe(deriveLoading.name, () => {
describe('For sync observable', () => {
it('should return false', (done) => {
const source$ = of(1).pipe(deriveLoading());

source$.subscribe((result) => {
expect(result).toBe(false);
done();
});
});
});

describe('For async observable', () => {
let testScheduler: TestScheduler;

beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
});

it('should emit false when source emits before threshold', () => {
testScheduler.run(({ cold, expectObservable }) => {
const source$ = timer(1).pipe(
deriveLoading({ threshold: 1, loadingTime: 2 }),
);
const expected = 'f--|';
expectObservable(source$).toBe(expected, { f: false });
});
});

// Operation takes longer than threshold
it('should emit false-true-false when source emits after threshold', () => {
testScheduler.run(({ cold, expectObservable }) => {
const source$ = timer(2).pipe(
deriveLoading({ threshold: 1, loadingTime: 2 }),
);
const expected = 'ft-(f|)';
expectObservable(source$).toBe(expected, { f: false, t: true });
});
});

// Operation takes longer than threshold + loadingTime
it('should emit false-true-false when source emits after threshold + loadingTime', () => {
testScheduler.run(({ cold, expectObservable }) => {
const source$ = timer(4).pipe(
deriveLoading({ threshold: 1, loadingTime: 2 }),
);
const expected = 'ft--(f|)';
expectObservable(source$).toBe(expected, { f: false, t: true });
});
});
});
});
92 changes: 92 additions & 0 deletions libs/ngxtension/derive-loading/src/derive-loading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
combineLatest,
debounce,
distinctUntilChanged,
map,
merge,
MonoTypeOperatorFunction,
Observable,
OperatorFunction,
ReplaySubject,
share,
startWith,
takeUntil,
timer,
} from 'rxjs';

export type DeriveLoadingOptions = {
/**
* The time in milliseconds to wait before emiting the loading flag = true.
*/
threshold?: number;
/**
* The time in milliseconds to wait before emiting the loading flag = false.
*/
loadingTime?: number;
};

/**
* Derive a loading state from the source observable.
*
* It will emit a loading flag in a "non-flickering" way. This means
* if the async operation finishes before the threshold time, the loading flag will not change
* to "true", it will stay false.
*
* It will only emit "true" when the async operation takes longer than the threshold time.
* It will change back to "false" after at least the defined threshold + loadingTime has passed.
* If the async operation takes longer than threshold + loadingtime, "false" will be emitted after the operation
* has finished.
*
* @param options - The options to configure the loading state derivation.
* @returns A observable that emits the loading flag.
*
* @param options
*/
export function deriveLoading<T>(
options?: DeriveLoadingOptions,
): OperatorFunction<T, boolean> {
const threshold = options?.threshold ?? 500;
const loadingTime = options?.loadingTime ?? 1000;

return function <T>(source: Observable<T>): Observable<boolean> {
const result$ = source.pipe(
share({
connector: () => new ReplaySubject(1),
resetOnComplete: false,
resetOnRefCountZero: true,
resetOnError: true,
}),
);

return merge(
timer(threshold).pipe(
map(() => true),
takeUntil(result$),
),
combineLatest([result$, timer(threshold + loadingTime)]).pipe(
map(() => false),
),
).pipe(startWith(false), distinctUntilChanged(), handleSyncValue());
};
}

function handleSyncValue<T>(): MonoTypeOperatorFunction<any> {
return (source$: Observable<T>): Observable<T> => {
return new Observable<T>((observer) => {
const isReadySubject = new ReplaySubject<unknown>(1);

const subscription = source$
.pipe(
/* Wait for all synchronous processing to be done. */
debounce(() => isReadySubject),
)
.subscribe(observer);

/* Sync emitted values have been processed now.
* Mark source as ready and emit last computed state. */
isReadySubject.next(undefined);

return () => subscription.unsubscribe();
});
};
}
1 change: 1 addition & 0 deletions libs/ngxtension/derive-loading/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DeriveLoadingOptions, deriveLoading } from './derive-loading';
3 changes: 3 additions & 0 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
"libs/ngxtension/create-signal/src/index.ts"
],
"ngxtension/debug": ["libs/ngxtension/debug/src/index.ts"],
"ngxtension/derive-loading": [
"libs/ngxtension/derive-loading/src/index.ts"
],
"ngxtension/derived-async": [
"libs/ngxtension/derived-async/src/index.ts"
],
Expand Down

0 comments on commit 559a89a

Please sign in to comment.