From bb4f063fe7e088deb7b9f4e1c1e9d03023c24da0 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Mon, 10 Oct 2022 16:04:29 +0200 Subject: [PATCH] [EC-558] Reflecting async progress on buttons and forms (#3548) * [EC-556] feat: convert button into component * [EC-556] feat: implement loading state * [EC-556] feat: remove loading from submit button * [EC-556] fix: add missing import * [EC-556] fix: disabling button using regular attribute * [EC-556] feat: implement bitFormButton * [EC-556] feat: use bitFormButton in submit button * [EC-556] fix: missing import * [EC-558] chore: rename file to match class name * [EC-558] feat: allow skipping bitButton on form buttons * [EC-558]: only show spinner on submit button * [EC-558] feat: add new bit async directive * [EC-558] feat: add functionToObservable util * [EC-558] feat: implement bitAction directive * [EC-558] refactor: simplify bitSubmit using functionToObservable * [EC-558] feat: connect bit action with form button * [EC-558] feat: execute function immediately to allow for form validation * [EC-558] feat: disable form on loading * [EC-558] chore: remove duplicate types * [EC-558] feat: move validation service to common * [EC-558] feat: add error handling using validation service * [EC-558] feat: add support for icon button * [EC-558] fix: icon button hover border styles * [EC-558] chore: refactor icon button story to show all styles * [EC-558] fix: better align loading spinner to middle * [EC-558] fix: simplify try catch * [EC-558] chore: reorganize async actions * [EC-558] chore: rename stories * [EC-558] docs: add documentation * [EC-558] feat: decouple buttons and form buttons * [EC-558] chore: rename button like abstraction * [EC-558] chore: remove null check * [EC-558] docs: add jsdocs to directives * [EC-558] fix: switch abs imports to relative * [EC-558] chore: add async actions module to web shared module * [EC-558] chore: remove unecessary null check * [EC-558] chore: apply suggestions from code review Co-authored-by: Oscar Hinton * [EC-558] fix: whitespaces * [EC-558] feat: dont disable form by default * [EC-558] fix: bug where form could be submit during a previous submit * [EC-558] feat: remove ability to disable form Co-authored-by: Oscar Hinton --- .../src/app/common/base.people.component.ts | 2 +- .../organizations/manage/people.component.ts | 2 +- ...families-for-enterprise-setup.component.ts | 2 +- apps/web/src/app/shared/shared.module.ts | 2 + .../clients/add-organization.component.ts | 2 +- .../providers/clients/clients.component.ts | 2 +- .../app/providers/manage/people.component.ts | 2 +- .../app/providers/setup/setup.component.ts | 2 +- .../src/directives/api-action.directive.ts | 3 +- .../src/services/jslib-services.module.ts | 9 +- .../src/abstractions/validation.service.ts | 3 + libs/common/src/misc/utils.ts | 6 + .../src/services/validation.service.ts | 12 +- .../src/async-actions/async-actions.module.ts | 14 ++ .../src/async-actions/bit-action.directive.ts | 58 +++++++ .../src/async-actions/bit-submit.directive.ts | 83 ++++++++++ .../async-actions/form-button.directive.ts | 58 +++++++ .../src/async-actions/in-forms.stories.mdx | 114 +++++++++++++ .../src/async-actions/in-forms.stories.ts | 156 ++++++++++++++++++ libs/components/src/async-actions/index.ts | 3 + .../src/async-actions/overview.stories.mdx | 26 +++ .../src/async-actions/standalone.stories.mdx | 63 +++++++ .../src/async-actions/standalone.stories.ts | 97 +++++++++++ .../src/button/button.component.html | 7 +- .../components/src/button/button.component.ts | 5 +- .../icon-button/icon-button.component.html | 15 ++ .../src/icon-button/icon-button.component.ts | 29 +++- .../src/icon-button/icon-button.stories.ts | 108 +++++++----- libs/components/src/index.ts | 1 + .../src/shared/button-like.abstraction.ts | 4 + .../src/utils/function-to-observable.spec.ts | 103 ++++++++++++ .../src/utils/function-to-observable.ts | 27 +++ 32 files changed, 955 insertions(+), 65 deletions(-) create mode 100644 libs/common/src/abstractions/validation.service.ts rename libs/{angular => common}/src/services/validation.service.ts (72%) create mode 100644 libs/components/src/async-actions/async-actions.module.ts create mode 100644 libs/components/src/async-actions/bit-action.directive.ts create mode 100644 libs/components/src/async-actions/bit-submit.directive.ts create mode 100644 libs/components/src/async-actions/form-button.directive.ts create mode 100644 libs/components/src/async-actions/in-forms.stories.mdx create mode 100644 libs/components/src/async-actions/in-forms.stories.ts create mode 100644 libs/components/src/async-actions/index.ts create mode 100644 libs/components/src/async-actions/overview.stories.mdx create mode 100644 libs/components/src/async-actions/standalone.stories.mdx create mode 100644 libs/components/src/async-actions/standalone.stories.ts create mode 100644 libs/components/src/icon-button/icon-button.component.html create mode 100644 libs/components/src/shared/button-like.abstraction.ts create mode 100644 libs/components/src/utils/function-to-observable.spec.ts create mode 100644 libs/components/src/utils/function-to-observable.ts diff --git a/apps/web/src/app/common/base.people.component.ts b/apps/web/src/app/common/base.people.component.ts index 8d9a332424..e7c2c8f4ea 100644 --- a/apps/web/src/app/common/base.people.component.ts +++ b/apps/web/src/app/common/base.people.component.ts @@ -3,7 +3,6 @@ import { Directive, ViewChild, ViewContainerRef } from "@angular/core"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -11,6 +10,7 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType"; import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; import { ProviderUserStatusType } from "@bitwarden/common/enums/providerUserStatusType"; diff --git a/apps/web/src/app/organizations/manage/people.component.ts b/apps/web/src/app/organizations/manage/people.component.ts index d8535c1435..94e9493431 100644 --- a/apps/web/src/app/organizations/manage/people.component.ts +++ b/apps/web/src/app/organizations/manage/people.component.ts @@ -5,7 +5,6 @@ import { first } from "rxjs/operators"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -18,6 +17,7 @@ import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.serv import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType"; import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; import { PolicyType } from "@bitwarden/common/enums/policyType"; diff --git a/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts b/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts index 7d7f819306..9ce3d59932 100644 --- a/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts +++ b/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts @@ -4,12 +4,12 @@ import { Observable, Subject } from "rxjs"; import { first, map, takeUntil } from "rxjs/operators"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { PlanSponsorshipType } from "@bitwarden/common/enums/planSponsorshipType"; import { PlanType } from "@bitwarden/common/enums/planType"; import { ProductType } from "@bitwarden/common/enums/productType"; diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index c0a076e545..f8c79ebb5e 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -14,6 +14,7 @@ import { FormFieldModule, MenuModule, IconModule, + AsyncActionsModule, } from "@bitwarden/components"; // Register the locales for the application @@ -47,6 +48,7 @@ import "./locales"; ], exports: [ CommonModule, + AsyncActionsModule, DragDropModule, FormsModule, InfiniteScrollModule, diff --git a/bitwarden_license/bit-web/src/app/providers/clients/add-organization.component.ts b/bitwarden_license/bit-web/src/app/providers/clients/add-organization.component.ts index 8544acc405..292c4649a9 100644 --- a/bitwarden_license/bit-web/src/app/providers/clients/add-organization.component.ts +++ b/bitwarden_license/bit-web/src/app/providers/clients/add-organization.component.ts @@ -1,9 +1,9 @@ import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { ProviderService } from "@bitwarden/common/abstractions/provider.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { Organization } from "@bitwarden/common/models/domain/organization"; import { Provider } from "@bitwarden/common/models/domain/provider"; diff --git a/bitwarden_license/bit-web/src/app/providers/clients/clients.component.ts b/bitwarden_license/bit-web/src/app/providers/clients/clients.component.ts index f98a7f3ac1..a87302511a 100644 --- a/bitwarden_license/bit-web/src/app/providers/clients/clients.component.ts +++ b/bitwarden_license/bit-web/src/app/providers/clients/clients.component.ts @@ -3,7 +3,6 @@ import { ActivatedRoute } from "@angular/router"; import { first } from "rxjs/operators"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; @@ -12,6 +11,7 @@ import { OrganizationService } from "@bitwarden/common/abstractions/organization import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { ProviderService } from "@bitwarden/common/abstractions/provider.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { PlanType } from "@bitwarden/common/enums/planType"; import { ProviderUserType } from "@bitwarden/common/enums/providerUserType"; import { Organization } from "@bitwarden/common/models/domain/organization"; diff --git a/bitwarden_license/bit-web/src/app/providers/manage/people.component.ts b/bitwarden_license/bit-web/src/app/providers/manage/people.component.ts index 2ffd0e7a53..bdb7c30e90 100644 --- a/bitwarden_license/bit-web/src/app/providers/manage/people.component.ts +++ b/bitwarden_license/bit-web/src/app/providers/manage/people.component.ts @@ -5,7 +5,6 @@ import { first } from "rxjs/operators"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -14,6 +13,7 @@ import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUti import { ProviderService } from "@bitwarden/common/abstractions/provider.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { ProviderUserStatusType } from "@bitwarden/common/enums/providerUserStatusType"; import { ProviderUserType } from "@bitwarden/common/enums/providerUserType"; import { ProviderUserBulkRequest } from "@bitwarden/common/models/request/provider/providerUserBulkRequest"; diff --git a/bitwarden_license/bit-web/src/app/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/providers/setup/setup.component.ts index 531f1e9e21..bdc9b49826 100644 --- a/bitwarden_license/bit-web/src/app/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/providers/setup/setup.component.ts @@ -2,12 +2,12 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { first } from "rxjs/operators"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { ProviderSetupRequest } from "@bitwarden/common/models/request/provider/providerSetupRequest"; @Component({ diff --git a/libs/angular/src/directives/api-action.directive.ts b/libs/angular/src/directives/api-action.directive.ts index 4a4e06201f..f68e7f8665 100644 --- a/libs/angular/src/directives/api-action.directive.ts +++ b/libs/angular/src/directives/api-action.directive.ts @@ -1,10 +1,9 @@ import { Directive, ElementRef, Input, OnChanges } from "@angular/core"; import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { ErrorResponse } from "@bitwarden/common/models/response/errorResponse"; -import { ValidationService } from "../services/validation.service"; - /** * Provides error handling, in particular for any error returned by the server in an api call. * Attach it to a
element and provide the name of the class property that will hold the api call promise. diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 83ed944bc8..b1bbcc0a87 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -55,6 +55,7 @@ import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/comm import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/abstractions/userVerification/userVerification-api.service.abstraction"; import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; import { UsernameGenerationService as UsernameGenerationServiceAbstraction } from "@bitwarden/common/abstractions/usernameGeneration.service"; +import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/abstractions/validation.service"; import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service"; import { StateFactory } from "@bitwarden/common/factories/stateFactory"; @@ -102,6 +103,7 @@ import { TwoFactorService } from "@bitwarden/common/services/twoFactor.service"; import { UserVerificationApiService } from "@bitwarden/common/services/userVerification/userVerification-api.service"; import { UserVerificationService } from "@bitwarden/common/services/userVerification/userVerification.service"; import { UsernameGenerationService } from "@bitwarden/common/services/usernameGeneration.service"; +import { ValidationService } from "@bitwarden/common/services/validation.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vaultTimeout/vaultTimeout.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vaultTimeout/vaultTimeoutSettings.service"; import { WebCryptoFunctionService } from "@bitwarden/common/services/webCryptoFunction.service"; @@ -127,12 +129,10 @@ import { ModalService } from "./modal.service"; import { PasswordRepromptService } from "./passwordReprompt.service"; import { ThemingService } from "./theming/theming.service"; import { AbstractThemingService } from "./theming/theming.service.abstraction"; -import { ValidationService } from "./validation.service"; @NgModule({ declarations: [], providers: [ - ValidationService, AuthGuard, UnauthGuard, LockGuard, @@ -561,6 +561,11 @@ import { ValidationService } from "./validation.service"; useClass: AnonymousHubService, deps: [EnvironmentServiceAbstraction, AuthServiceAbstraction, LogService], }, + { + provide: ValidationServiceAbstraction, + useClass: ValidationService, + deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction], + }, ], }) export class JslibServicesModule {} diff --git a/libs/common/src/abstractions/validation.service.ts b/libs/common/src/abstractions/validation.service.ts new file mode 100644 index 0000000000..c0985847bf --- /dev/null +++ b/libs/common/src/abstractions/validation.service.ts @@ -0,0 +1,3 @@ +export abstract class ValidationService { + showError: (data: any) => string[]; +} diff --git a/libs/common/src/misc/utils.ts b/libs/common/src/misc/utils.ts index 21ddb651e0..f3f03c30ee 100644 --- a/libs/common/src/misc/utils.ts +++ b/libs/common/src/misc/utils.ts @@ -339,6 +339,12 @@ export class Utils { return str == null || typeof str !== "string" || str == ""; } + static isPromise(obj: any): obj is Promise { + return ( + obj != undefined && typeof obj["then"] === "function" && typeof obj["catch"] === "function" + ); + } + static nameOf(name: string & keyof T) { return name; } diff --git a/libs/angular/src/services/validation.service.ts b/libs/common/src/services/validation.service.ts similarity index 72% rename from libs/angular/src/services/validation.service.ts rename to libs/common/src/services/validation.service.ts index a9d4dbbf33..b51d55a2d8 100644 --- a/libs/angular/src/services/validation.service.ts +++ b/libs/common/src/services/validation.service.ts @@ -1,11 +1,9 @@ -import { Injectable } from "@angular/core"; +import { I18nService } from "../abstractions/i18n.service"; +import { PlatformUtilsService } from "../abstractions/platformUtils.service"; +import { ValidationService as ValidationServiceAbstraction } from "../abstractions/validation.service"; +import { ErrorResponse } from "../models/response/errorResponse"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { ErrorResponse } from "@bitwarden/common/models/response/errorResponse"; - -@Injectable() -export class ValidationService { +export class ValidationService implements ValidationServiceAbstraction { constructor( private i18nService: I18nService, private platformUtilsService: PlatformUtilsService diff --git a/libs/components/src/async-actions/async-actions.module.ts b/libs/components/src/async-actions/async-actions.module.ts new file mode 100644 index 0000000000..8ff1deb278 --- /dev/null +++ b/libs/components/src/async-actions/async-actions.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from "@angular/core"; + +import { SharedModule } from "../shared"; + +import { BitActionDirective } from "./bit-action.directive"; +import { BitSubmitDirective } from "./bit-submit.directive"; +import { BitFormButtonDirective } from "./form-button.directive"; + +@NgModule({ + imports: [SharedModule], + declarations: [BitActionDirective, BitFormButtonDirective, BitSubmitDirective], + exports: [BitActionDirective, BitFormButtonDirective, BitSubmitDirective], +}) +export class AsyncActionsModule {} diff --git a/libs/components/src/async-actions/bit-action.directive.ts b/libs/components/src/async-actions/bit-action.directive.ts new file mode 100644 index 0000000000..4fb28a4b1f --- /dev/null +++ b/libs/components/src/async-actions/bit-action.directive.ts @@ -0,0 +1,58 @@ +import { Directive, HostListener, Input, OnDestroy, Optional } from "@angular/core"; +import { BehaviorSubject, finalize, Subject, takeUntil, tap } from "rxjs"; + +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; + +import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; +import { FunctionReturningAwaitable, functionToObservable } from "../utils/function-to-observable"; + +/** + * Allow a single button to perform async actions on click and reflect the progress in the UI by automatically + * activating the loading effect while the action is processed. + */ +@Directive({ + selector: "[bitAction]", +}) +export class BitActionDirective implements OnDestroy { + private destroy$ = new Subject(); + private _loading$ = new BehaviorSubject(false); + + @Input("bitAction") protected handler: FunctionReturningAwaitable; + + readonly loading$ = this._loading$.asObservable(); + + constructor( + private buttonComponent: ButtonLikeAbstraction, + @Optional() private validationService?: ValidationService + ) {} + + get loading() { + return this._loading$.value; + } + + set loading(value: boolean) { + this._loading$.next(value); + this.buttonComponent.loading = value; + } + + @HostListener("click") + protected async onClick() { + if (!this.handler) { + return; + } + + this.loading = true; + functionToObservable(this.handler) + .pipe( + tap({ error: (err: unknown) => this.validationService?.showError(err) }), + finalize(() => (this.loading = false)), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/libs/components/src/async-actions/bit-submit.directive.ts b/libs/components/src/async-actions/bit-submit.directive.ts new file mode 100644 index 0000000000..334696576b --- /dev/null +++ b/libs/components/src/async-actions/bit-submit.directive.ts @@ -0,0 +1,83 @@ +import { Directive, Input, OnDestroy, OnInit, Optional } from "@angular/core"; +import { FormGroupDirective } from "@angular/forms"; +import { BehaviorSubject, catchError, filter, of, Subject, switchMap, takeUntil } from "rxjs"; + +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; + +import { FunctionReturningAwaitable, functionToObservable } from "../utils/function-to-observable"; + +/** + * Allow a form to perform async actions on submit, disabling the form while the action is processing. + */ +@Directive({ + selector: "[formGroup][bitSubmit]", +}) +export class BitSubmitDirective implements OnInit, OnDestroy { + private destroy$ = new Subject(); + private _loading$ = new BehaviorSubject(false); + private _disabled$ = new BehaviorSubject(false); + + @Input("bitSubmit") protected handler: FunctionReturningAwaitable; + @Input("disableFormOnLoading") protected disableFormOnLoading = false; + + readonly loading$ = this._loading$.asObservable(); + readonly disabled$ = this._disabled$.asObservable(); + + constructor( + private formGroupDirective: FormGroupDirective, + @Optional() validationService?: ValidationService + ) { + formGroupDirective.ngSubmit + .pipe( + filter(() => !this.disabled), + switchMap(() => { + // Calling functionToObservable exectues the sync part of the handler + // allowing the function to check form validity before it gets disabled. + const awaitable = functionToObservable(this.handler); + + // Disable form + this.loading = true; + + return awaitable.pipe( + catchError((err: unknown) => { + validationService?.showError(err); + return of(undefined); + }) + ); + }), + takeUntil(this.destroy$) + ) + .subscribe({ + next: () => (this.loading = false), + complete: () => (this.loading = false), + }); + } + + ngOnInit(): void { + this.formGroupDirective.statusChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((c) => this._disabled$.next(c === "DISABLED")); + } + + get disabled() { + return this._disabled$.value; + } + + set disabled(value: boolean) { + this._disabled$.next(value); + } + + get loading() { + return this._loading$.value; + } + + set loading(value: boolean) { + this.disabled = value; + this._loading$.next(value); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/libs/components/src/async-actions/form-button.directive.ts b/libs/components/src/async-actions/form-button.directive.ts new file mode 100644 index 0000000000..20ca289f7b --- /dev/null +++ b/libs/components/src/async-actions/form-button.directive.ts @@ -0,0 +1,58 @@ +import { Directive, Input, OnDestroy, Optional } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; + +import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; + +import { BitSubmitDirective } from "./bit-submit.directive"; + +import { BitActionDirective } from "."; + +/** + * This directive has two purposes: + * + * When attached to a submit button: + * - Activates the button loading effect while the form is processing an async submit action. + * - Disables the button while a `bitAction` directive on another button is being processed. + * + * When attached to a standalone button with `bitAction` directive: + * - Disables the form while the `bitAction` directive is processing an async submit action. + */ +@Directive({ + selector: "button[bitFormButton]", +}) +export class BitFormButtonDirective implements OnDestroy { + private destroy$ = new Subject(); + + @Input() type: string; + + constructor( + buttonComponent: ButtonLikeAbstraction, + @Optional() submitDirective?: BitSubmitDirective, + @Optional() actionDirective?: BitActionDirective + ) { + if (submitDirective && buttonComponent) { + submitDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => { + if (this.type === "submit") { + buttonComponent.loading = loading; + } else { + buttonComponent.disabled = loading; + } + }); + + submitDirective.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => { + buttonComponent.disabled = disabled; + }); + } + + if (submitDirective && actionDirective) { + actionDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => { + submitDirective.disabled = disabled; + }); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/libs/components/src/async-actions/in-forms.stories.mdx b/libs/components/src/async-actions/in-forms.stories.mdx new file mode 100644 index 0000000000..75bedda8eb --- /dev/null +++ b/libs/components/src/async-actions/in-forms.stories.mdx @@ -0,0 +1,114 @@ +import { Meta } from "@storybook/addon-docs"; + + + +# Async Actions In Forms + +These directives should be used when building forms with buttons that trigger long running tasks in the background, +eg. Submit or Delete buttons. For buttons that are not associated with a form see [Standalone Async Actions](?path=/story/component-library-async-actions-standalone-documentation--page). + +There are two separately supported use-cases: Submit buttons and standalone form buttons (eg. Delete buttons). + +## Usage: Submit buttons + +Adding async actions to submit buttons requires the following 3 steps + +### 1. Add a handler to your `Component` + +A handler is a function that returns a promise or an observable. Functions that return `void` are also supported which is +useful for aborting an action. + +**NOTE:** + +- Defining the handlers as arrow-functions assigned to variables is mandatory if the handler needs access to the parent + component using the variable `this`. +- `formGroup.invalid` will always return `true` after the first `await` operation, event if the form is not actually + invalid. This is due to the form getting disabled by the `bitSubmit` directive while waiting for the async action to complete. + +```ts +@Component({...}) +class Component { + formGroup = this.formBuilder.group({...}); + + // submit can also return Observable instead of Promise + submit = async () => { + if (this.formGroup.invalid) { + return; + } + + await this.cryptoService.encrypt(/* ... */); + + // `formGroup.invalid` will always return `true` here + + await this.apiService.post(/* ... */); + } +} +``` + +### 2. Add directive to the `form` element + +Add the `bitSubmit` directive and supply the handler defined in step 1. + +**NOTE:** The `directive` is defined using the input syntax: `[input]="handler"`. +This is different from how submit handlers are usually defined with the output syntax `(ngSubmit)="handler()"`. + +```html +... +``` + +### 3. Add directive to the `type="submit"` button + +Add both `bitButton` and `bitFormButton` directives to the button. + +```html + +``` + +## Usage: Standalone form buttons + +Adding async actions to standalone form buttons requires the following 3 steps. + +### 1. Add a handler to your `Component` + +A handler is a function that returns a promise or an observable. Functions that return `void` are also supported which is +useful for aborting an action. + +**NOTE:** Defining the handlers as arrow-functions assigned to variables is mandatory if the handler needs access to the parent +component using the variable `this`. + +```ts +@Component({...}) +class Component { + formGroup = this.formBuilder.group({...}); + + submit = async () => { + // not relevant for this example + } + + // action can also return Observable instead of Promise + handler = async () => { + if (/* perform guard check */) { + return; + } + + await this.apiService.post(/* ... */); + }; +} +``` + +### 2. Add directive to the `form` element + +The `bitSubmit` directive is required beacuse of its coordinating role. + +```html +
...
+``` + +### 3. Add directives to the `button` element + +Add `bitButton`, `bitFormButton`, `bitAction` directives to the button. Make sure to supply a handler. + +```html + + +``` diff --git a/libs/components/src/async-actions/in-forms.stories.ts b/libs/components/src/async-actions/in-forms.stories.ts new file mode 100644 index 0000000000..ce0528cc04 --- /dev/null +++ b/libs/components/src/async-actions/in-forms.stories.ts @@ -0,0 +1,156 @@ +import { Component, Input } from "@angular/core"; +import { FormsModule, ReactiveFormsModule, Validators, FormBuilder } from "@angular/forms"; +import { action } from "@storybook/addon-actions"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; +import { delay, of } from "rxjs"; + +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { I18nService } from "@bitwarden/common/src/abstractions/i18n.service"; + +import { ButtonModule } from "../button"; +import { FormFieldModule } from "../form-field"; +import { IconButtonModule } from "../icon-button"; +import { InputModule } from "../input/input.module"; +import { I18nMockService } from "../utils/i18n-mock.service"; + +import { BitActionDirective } from "./bit-action.directive"; +import { BitSubmitDirective } from "./bit-submit.directive"; +import { BitFormButtonDirective } from "./form-button.directive"; + +const template = ` +
+ + Name + + + + + Email + + + + + + + +
`; + +@Component({ + selector: "app-promise-example", + template, +}) +class PromiseExampleComponent { + formObj = this.formBuilder.group({ + name: ["", [Validators.required]], + email: ["", [Validators.required, Validators.email]], + }); + + @Input() disableFormOnLoading: boolean; + + constructor(private formBuilder: FormBuilder) {} + + submit = async () => { + this.formObj.markAllAsTouched(); + + if (!this.formObj.valid) { + return; + } + + await new Promise((resolve, reject) => { + setTimeout(resolve, 2000); + }); + }; + + delete = async () => { + await new Promise((resolve, reject) => { + setTimeout(resolve, 2000); + }); + }; +} + +@Component({ + selector: "app-observable-example", + template, +}) +class ObservableExampleComponent { + formObj = this.formBuilder.group({ + name: ["", [Validators.required]], + email: ["", [Validators.required, Validators.email]], + }); + + @Input() disableFormOnLoading: boolean; + + constructor(private formBuilder: FormBuilder) {} + + submit = () => { + this.formObj.markAllAsTouched(); + + if (!this.formObj.valid) { + return undefined; + } + + return of("fake observable").pipe(delay(2000)); + }; + + delete = () => { + return of("fake observable").pipe(delay(2000)); + }; +} + +export default { + title: "Component Library/Async Actions/In Forms", + decorators: [ + moduleMetadata({ + declarations: [ + BitSubmitDirective, + BitFormButtonDirective, + PromiseExampleComponent, + ObservableExampleComponent, + BitActionDirective, + ], + imports: [ + FormsModule, + ReactiveFormsModule, + FormFieldModule, + InputModule, + ButtonModule, + IconButtonModule, + ], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + required: "required", + inputRequired: "Input is required.", + inputEmail: "Input is not an email-address.", + }); + }, + }, + { + provide: ValidationService, + useValue: { + showError: action("ValidationService.showError"), + } as Partial, + }, + ], + }), + ], + args: { + disableFormOnLoading: false, + }, +} as Meta; + +const PromiseTemplate: Story = (args: PromiseExampleComponent) => ({ + props: args, + template: ``, +}); + +export const UsingPromise = PromiseTemplate.bind({}); + +const ObservableTemplate: Story = (args: PromiseExampleComponent) => ({ + props: args, + template: ``, +}); + +export const UsingObservable = ObservableTemplate.bind({}); diff --git a/libs/components/src/async-actions/index.ts b/libs/components/src/async-actions/index.ts new file mode 100644 index 0000000000..6515ffc47c --- /dev/null +++ b/libs/components/src/async-actions/index.ts @@ -0,0 +1,3 @@ +export * from "./async-actions.module"; +export * from "./bit-action.directive"; +export * from "./form-button.directive"; diff --git a/libs/components/src/async-actions/overview.stories.mdx b/libs/components/src/async-actions/overview.stories.mdx new file mode 100644 index 0000000000..9ec792aefd --- /dev/null +++ b/libs/components/src/async-actions/overview.stories.mdx @@ -0,0 +1,26 @@ +import { Meta } from "@storybook/addon-docs"; + + + +# Async Actions + +The directives in this module makes it easier for developers to reflect the progress of async actions in the UI when using +buttons, while also providing robust and standardized error handling. + +These buttons can either be standalone (such as Refresh buttons), submit buttons for forms or as standalone buttons +that are part of a form (such as Delete buttons). + +These directives are meant to replace the older `appApiAction` directive, providing the option to use `observables` and reduce +clutter inside our view `components`. + +## When to use? + +When building a button that triggers a long running task in the background eg. server API calls. + +## Why? + +To better visualize that the application is processing their request. + +## What does it do? + +It disables buttons and show a spinning animation. diff --git a/libs/components/src/async-actions/standalone.stories.mdx b/libs/components/src/async-actions/standalone.stories.mdx new file mode 100644 index 0000000000..7ed5c46ffd --- /dev/null +++ b/libs/components/src/async-actions/standalone.stories.mdx @@ -0,0 +1,63 @@ +import { Meta } from "@storybook/addon-docs"; + + + +# Standalone Async Actions + +These directives should be used when building a standalone button that triggers a long running task in the background, +eg. Refresh buttons. For non-submit buttons that are associated with forms see [Async Actions In Forms](?path=/story/component-library-async-actions-in-forms-documentation--page). + +## Usage + +Adding async actions to standalone buttons requires the following 2 steps + +### 1. Add a handler to your `Component` + +A handler is a function that returns a promise or an observable. Functions that return `void` are also supported which is +useful for aborting an action. + +**NOTE:** Defining the handlers as arrow-functions assigned to variables is mandatory if the handler needs access to the parent +component using the variable `this`. + +#### Example using promises + +```ts +@Component({...}) +class PromiseExampleComponent { + handler = async () => { + if (/* perform guard check */) { + return; + } + + await this.apiService.post(/* ... */); + }; +} +``` + +#### Example using observables + +```ts +@Component({...}) +class Component { + handler = () => { + if (/* perform guard check */) { + return; + } + + return this.apiService.post$(/* ... */); + }; +} +``` + +### 2. Add directive to the DOM element + +Add the `bitAction` directive and supply the handler defined in step 1. + +**NOTE:** The `directive` is defined using the input syntax: `[input]="handler"`. +This is different from how click handlers are usually defined with the output syntax `(click)="handler()"`. + +```html + + +`; +``` diff --git a/libs/components/src/async-actions/standalone.stories.ts b/libs/components/src/async-actions/standalone.stories.ts new file mode 100644 index 0000000000..cd0c6239b0 --- /dev/null +++ b/libs/components/src/async-actions/standalone.stories.ts @@ -0,0 +1,97 @@ +import { Component } from "@angular/core"; +import { action } from "@storybook/addon-actions"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; +import { delay, of } from "rxjs"; + +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; + +import { ButtonModule } from "../button"; +import { IconButtonModule } from "../icon-button"; + +import { BitActionDirective } from "./bit-action.directive"; + +const template = ` + + `; + +@Component({ + template, + selector: "app-promise-example", +}) +class PromiseExampleComponent { + action = async () => { + await new Promise((resolve, reject) => { + setTimeout(resolve, 2000); + }); + }; +} + +@Component({ + template, + selector: "app-observable-example", +}) +class ObservableExampleComponent { + action = () => { + return of("fake observable").pipe(delay(2000)); + }; +} + +@Component({ + template, + selector: "app-rejected-promise-example", +}) +class RejectedPromiseExampleComponent { + action = async () => { + await new Promise((resolve, reject) => { + setTimeout(() => reject(new Error("Simulated error")), 2000); + }); + }; +} + +export default { + title: "Component Library/Async Actions/Standalone", + decorators: [ + moduleMetadata({ + declarations: [ + BitActionDirective, + PromiseExampleComponent, + ObservableExampleComponent, + RejectedPromiseExampleComponent, + ], + imports: [ButtonModule, IconButtonModule], + providers: [ + { + provide: ValidationService, + useValue: { + showError: action("ValidationService.showError"), + } as Partial, + }, + ], + }), + ], +} as Meta; + +const PromiseTemplate: Story = (args: PromiseExampleComponent) => ({ + props: args, + template: ``, +}); + +export const UsingPromise = PromiseTemplate.bind({}); + +const ObservableTemplate: Story = ( + args: ObservableExampleComponent +) => ({ + template: ``, +}); + +export const UsingObservable = ObservableTemplate.bind({}); + +const RejectedPromiseTemplate: Story = ( + args: ObservableExampleComponent +) => ({ + template: ``, +}); + +export const RejectedPromise = RejectedPromiseTemplate.bind({}); diff --git a/libs/components/src/button/button.component.html b/libs/components/src/button/button.component.html index 4875c159e9..ee4d150dfc 100644 --- a/libs/components/src/button/button.component.html +++ b/libs/components/src/button/button.component.html @@ -2,7 +2,10 @@ - - + + diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index eeba83b815..9a27bfdd9e 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -1,5 +1,7 @@ import { Input, HostBinding, Component } from "@angular/core"; +import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; + export type ButtonTypes = "primary" | "secondary" | "danger"; const buttonStyles: Record = { @@ -41,8 +43,9 @@ const buttonStyles: Record = { @Component({ selector: "button[bitButton], a[bitButton]", templateUrl: "button.component.html", + providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }], }) -export class ButtonComponent { +export class ButtonComponent implements ButtonLikeAbstraction { @HostBinding("class") get classList() { return [ "tw-font-semibold", diff --git a/libs/components/src/icon-button/icon-button.component.html b/libs/components/src/icon-button/icon-button.component.html new file mode 100644 index 0000000000..6eeaaaffaf --- /dev/null +++ b/libs/components/src/icon-button/icon-button.component.html @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 6696935257..ef9474bf98 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -1,8 +1,10 @@ import { Component, HostBinding, Input } from "@angular/core"; -export type IconButtonStyle = "contrast" | "main" | "muted" | "primary" | "secondary" | "danger"; +import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; -const styles: Record = { +export type IconButtonType = "contrast" | "main" | "muted" | "primary" | "secondary" | "danger"; + +const styles: Record = { contrast: [ "tw-bg-transparent", "!tw-text-contrast", @@ -10,6 +12,7 @@ const styles: Record = { "hover:tw-bg-transparent-hover", "hover:tw-border-text-contrast", "focus-visible:before:tw-ring-text-contrast", + "disabled:hover:tw-border-transparent", "disabled:hover:tw-bg-transparent", ], main: [ @@ -19,6 +22,7 @@ const styles: Record = { "hover:tw-bg-transparent-hover", "hover:tw-border-text-main", "focus-visible:before:tw-ring-text-main", + "disabled:hover:tw-border-transparent", "disabled:hover:tw-bg-transparent", ], muted: [ @@ -28,6 +32,7 @@ const styles: Record = { "hover:tw-bg-transparent-hover", "hover:tw-border-primary-700", "focus-visible:before:tw-ring-primary-700", + "disabled:hover:tw-border-transparent", "disabled:hover:tw-bg-transparent", ], primary: [ @@ -37,6 +42,7 @@ const styles: Record = { "hover:tw-bg-primary-700", "hover:tw-border-primary-700", "focus-visible:before:tw-ring-primary-700", + "disabled:hover:tw-border-primary-500", "disabled:hover:tw-bg-primary-500", ], secondary: [ @@ -46,6 +52,7 @@ const styles: Record = { "hover:!tw-text-contrast", "hover:tw-bg-text-muted", "focus-visible:before:tw-ring-primary-700", + "disabled:hover:tw-border-text-muted", "disabled:hover:tw-bg-transparent", "disabled:hover:!tw-text-muted", "disabled:hover:tw-border-text-muted", @@ -57,6 +64,7 @@ const styles: Record = { "hover:!tw-text-contrast", "hover:tw-bg-danger-500", "focus-visible:before:tw-ring-primary-700", + "disabled:hover:tw-border-danger-500", "disabled:hover:tw-bg-transparent", "disabled:hover:!tw-text-danger", "disabled:hover:tw-border-danger-500", @@ -72,12 +80,13 @@ const sizes: Record = { @Component({ selector: "button[bitIconButton]", - template: ``, + templateUrl: "icon-button.component.html", + providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }], }) -export class BitIconButtonComponent { +export class BitIconButtonComponent implements ButtonLikeAbstraction { @Input("bitIconButton") icon: string; - @Input() buttonType: IconButtonStyle = "main"; + @Input() buttonType: IconButtonType = "main"; @Input() size: IconButtonSize = "default"; @@ -90,7 +99,6 @@ export class BitIconButtonComponent { "tw-transition", "hover:tw-no-underline", "disabled:tw-opacity-60", - "disabled:hover:tw-border-transparent", "focus:tw-outline-none", // Workaround for box-shadow with transparent offset issue: @@ -117,4 +125,13 @@ export class BitIconButtonComponent { get iconClass() { return [this.icon, "!tw-m-0"]; } + + @HostBinding("attr.disabled") + get disabledAttr() { + const disabled = this.disabled != null && this.disabled !== false; + return disabled || this.loading ? true : null; + } + + @Input() loading = false; + @Input() disabled = false; } diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts index 7be45d3f71..69350945d9 100644 --- a/libs/components/src/icon-button/icon-button.stories.ts +++ b/libs/components/src/icon-button/icon-button.stories.ts @@ -1,59 +1,91 @@ import { Meta, Story } from "@storybook/angular"; -import { BitIconButtonComponent } from "./icon-button.component"; +import { BitIconButtonComponent, IconButtonType } from "./icon-button.component"; + +const buttonTypes: IconButtonType[] = [ + "contrast", + "main", + "muted", + "primary", + "secondary", + "danger", +]; export default { title: "Component Library/Icon Button", component: BitIconButtonComponent, args: { bitIconButton: "bwi-plus", - buttonType: "primary", size: "default", disabled: false, }, + argTypes: { + buttonTypes: { table: { disable: true } }, + }, } as Meta; const Template: Story = (args: BitIconButtonComponent) => ({ - props: args, + props: { ...args, buttonTypes }, template: ` -
- -
+ + + + + + + + + + + + + + + + + + + + + + + + +
{{buttonType}}
Default + +
Disabled + +
Loading + +
`, }); -export const Contrast = Template.bind({}); -Contrast.args = { - buttonType: "contrast", +export const Default = Template.bind({}); +Default.args = { + size: "default", }; -export const Main = Template.bind({}); -Main.args = { - buttonType: "main", -}; - -export const Muted = Template.bind({}); -Muted.args = { - buttonType: "muted", -}; - -export const Primary = Template.bind({}); -Primary.args = { - buttonType: "primary", -}; - -export const Secondary = Template.bind({}); -Secondary.args = { - buttonType: "secondary", -}; - -export const Danger = Template.bind({}); -Danger.args = { - buttonType: "danger", +export const Small = Template.bind({}); +Small.args = { + size: "small", }; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 264c655d80..24095107a3 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -1,3 +1,4 @@ +export * from "./async-actions"; export * from "./badge"; export * from "./banner"; export * from "./button"; diff --git a/libs/components/src/shared/button-like.abstraction.ts b/libs/components/src/shared/button-like.abstraction.ts new file mode 100644 index 0000000000..21c57461d2 --- /dev/null +++ b/libs/components/src/shared/button-like.abstraction.ts @@ -0,0 +1,4 @@ +export abstract class ButtonLikeAbstraction { + loading: boolean; + disabled: boolean; +} diff --git a/libs/components/src/utils/function-to-observable.spec.ts b/libs/components/src/utils/function-to-observable.spec.ts new file mode 100644 index 0000000000..17b05d817f --- /dev/null +++ b/libs/components/src/utils/function-to-observable.spec.ts @@ -0,0 +1,103 @@ +import { lastValueFrom, Observable, of, throwError } from "rxjs"; + +import { functionToObservable } from "./function-to-observable"; + +describe("functionToObservable", () => { + it("should execute function when calling", () => { + const func = jest.fn(); + + functionToObservable(func); + + expect(func).toHaveBeenCalled(); + }); + + it("should not subscribe when calling", () => { + let hasSubscribed = false; + const underlyingObservable = new Observable(() => { + hasSubscribed = true; + }); + const funcReturningObservable = () => underlyingObservable; + + functionToObservable(funcReturningObservable); + + expect(hasSubscribed).toBe(false); + }); + + it("should subscribe to underlying when subscribing to outer", () => { + let hasSubscribed = false; + const underlyingObservable = new Observable(() => { + hasSubscribed = true; + }); + const funcReturningObservable = () => underlyingObservable; + const outerObservable = functionToObservable(funcReturningObservable); + + outerObservable.subscribe(); + + expect(hasSubscribed).toBe(true); + }); + + it("should return value when using sync function", async () => { + const value = Symbol(); + const func = () => value; + const observable = functionToObservable(func); + + const result = await lastValueFrom(observable); + + expect(result).toBe(value); + }); + + it("should return value when using async function", async () => { + const value = Symbol(); + const func = () => Promise.resolve(value); + const observable = functionToObservable(func); + + const result = await lastValueFrom(observable); + + expect(result).toBe(value); + }); + + it("should return value when using observable", async () => { + const value = Symbol(); + const func = () => of(value); + const observable = functionToObservable(func); + + const result = await lastValueFrom(observable); + + expect(result).toBe(value); + }); + + it("should throw error when using sync function", async () => { + const error = new Error(); + const func = () => { + throw error; + }; + const observable = functionToObservable(func); + + let thrown: unknown; + observable.subscribe({ error: (err: unknown) => (thrown = err) }); + + expect(thrown).toBe(thrown); + }); + + it("should return value when using async function", async () => { + const error = new Error(); + const func = () => Promise.reject(error); + const observable = functionToObservable(func); + + let thrown: unknown; + observable.subscribe({ error: (err: unknown) => (thrown = err) }); + + expect(thrown).toBe(thrown); + }); + + it("should return value when using observable", async () => { + const error = new Error(); + const func = () => throwError(() => error); + const observable = functionToObservable(func); + + let thrown: unknown; + observable.subscribe({ error: (err: unknown) => (thrown = err) }); + + expect(thrown).toBe(thrown); + }); +}); diff --git a/libs/components/src/utils/function-to-observable.ts b/libs/components/src/utils/function-to-observable.ts new file mode 100644 index 0000000000..ab619ae75c --- /dev/null +++ b/libs/components/src/utils/function-to-observable.ts @@ -0,0 +1,27 @@ +import { from, Observable, of, throwError } from "rxjs"; + +import { Utils } from "@bitwarden/common/misc/utils"; + +export type FunctionReturningAwaitable = + | (() => unknown) + | (() => Promise) + | (() => Observable); + +export function functionToObservable(func: FunctionReturningAwaitable): Observable { + let awaitable: unknown; + try { + awaitable = func(); + } catch (error) { + return throwError(() => error); + } + + if (Utils.isPromise(awaitable)) { + return from(awaitable); + } + + if (awaitable instanceof Observable) { + return awaitable; + } + + return of(awaitable); +}