diff --git a/libs/ngxtension/derive-loading/README.md b/libs/ngxtension/derive-loading/README.md new file mode 100644 index 00000000..8ec220a9 --- /dev/null +++ b/libs/ngxtension/derive-loading/README.md @@ -0,0 +1,3 @@ +# ngxtension/derive-loading + +Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/derive-loading`. diff --git a/libs/ngxtension/derive-loading/ng-package.json b/libs/ngxtension/derive-loading/ng-package.json new file mode 100644 index 00000000..b3e53d69 --- /dev/null +++ b/libs/ngxtension/derive-loading/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/ngxtension/derive-loading/project.json b/libs/ngxtension/derive-loading/project.json new file mode 100644 index 00000000..0d4c1a8d --- /dev/null +++ b/libs/ngxtension/derive-loading/project.json @@ -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}"] + } + } +} diff --git a/libs/ngxtension/derive-loading/src/derive-loading.spec.ts b/libs/ngxtension/derive-loading/src/derive-loading.spec.ts new file mode 100644 index 00000000..a96c9e7d --- /dev/null +++ b/libs/ngxtension/derive-loading/src/derive-loading.spec.ts @@ -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 }); + }); + }); + }); +}); diff --git a/libs/ngxtension/derive-loading/src/derive-loading.ts b/libs/ngxtension/derive-loading/src/derive-loading.ts new file mode 100644 index 00000000..b265dbab --- /dev/null +++ b/libs/ngxtension/derive-loading/src/derive-loading.ts @@ -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( + options?: DeriveLoadingOptions, +): OperatorFunction { + const threshold = options?.threshold ?? 500; + const loadingTime = options?.loadingTime ?? 1000; + + return function (source: Observable): Observable { + 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(): MonoTypeOperatorFunction { + return (source$: Observable): Observable => { + return new Observable((observer) => { + const isReadySubject = new ReplaySubject(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(); + }); + }; +} diff --git a/libs/ngxtension/derive-loading/src/index.ts b/libs/ngxtension/derive-loading/src/index.ts new file mode 100644 index 00000000..500563e6 --- /dev/null +++ b/libs/ngxtension/derive-loading/src/index.ts @@ -0,0 +1 @@ +export { DeriveLoadingOptions, deriveLoading } from './derive-loading'; diff --git a/libs/plugin/.eslintrc.json b/libs/plugin/.eslintrc.json index b285f8f7..4860b7f3 100644 --- a/libs/plugin/.eslintrc.json +++ b/libs/plugin/.eslintrc.json @@ -19,7 +19,7 @@ "parser": "jsonc-eslint-parser", "rules": { "@nx/dependency-checks": [ - "error", + "warning", { "ignoredDependencies": [ "tslib", @@ -36,7 +36,7 @@ "files": ["./package.json", "./generators.json"], "parser": "jsonc-eslint-parser", "rules": { - "@nx/nx-plugin-checks": "error" + "@nx/nx-plugin-checks": "warning" } } ] diff --git a/tsconfig.base.json b/tsconfig.base.json index 53c18c5c..d973a266 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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" ],