[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 <Hinton@users.noreply.github.com>

* [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 <Hinton@users.noreply.github.com>
This commit is contained in:
Andreas Coroiu 2022-10-10 16:04:29 +02:00 committed by GitHub
parent 96c99058c4
commit bb4f063fe7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 955 additions and 65 deletions

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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,

View File

@ -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";

View File

@ -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";

View File

@ -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";

View File

@ -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({

View File

@ -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 <form> element and provide the name of the class property that will hold the api call promise.

View File

@ -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 {}

View File

@ -0,0 +1,3 @@
export abstract class ValidationService {
showError: (data: any) => string[];
}

View File

@ -339,6 +339,12 @@ export class Utils {
return str == null || typeof str !== "string" || str == "";
}
static isPromise(obj: any): obj is Promise<unknown> {
return (
obj != undefined && typeof obj["then"] === "function" && typeof obj["catch"] === "function"
);
}
static nameOf<T>(name: string & keyof T) {
return name;
}

View File

@ -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

View File

@ -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 {}

View File

@ -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<void>();
private _loading$ = new BehaviorSubject<boolean>(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();
}
}

View File

@ -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<void>();
private _loading$ = new BehaviorSubject<boolean>(false);
private _disabled$ = new BehaviorSubject<boolean>(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();
}
}

View File

@ -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<void>();
@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();
}
}

View File

@ -0,0 +1,114 @@
import { Meta } from "@storybook/addon-docs";
<Meta title="Component Library/Async Actions/In Forms/Documentation" />
# 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
<form [formGroup]="formGroup" [bitSubmit]="submit">...</form>
```
### 3. Add directive to the `type="submit"` button
Add both `bitButton` and `bitFormButton` directives to the button.
```html
<button type="submit" bitButton bitFormButton>{{ "submit" | i18n }}</button>
```
## 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
<form [formGroup]="formGroup" [bitSubmit]="submit">...</form>
```
### 3. Add directives to the `button` element
Add `bitButton`, `bitFormButton`, `bitAction` directives to the button. Make sure to supply a handler.
```html
<button type="button" bitFormButton bitButton [bitAction]="handler">Do action</button>
<button type="button" bitFormButton bitIconButton="bwi-star" [bitAction]="handler"></button>
```

View File

@ -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 = `
<form [formGroup]="formObj" [bitSubmit]="submit" [disableFormOnLoading]="disableFormOnLoading">
<bit-form-field>
<bit-label>Name</bit-label>
<input bitInput formControlName="name" />
</bit-form-field>
<bit-form-field>
<bit-label>Email</bit-label>
<input bitInput formControlName="email" />
</bit-form-field>
<button class="tw-mr-2" type="submit" buttonType="primary" bitButton bitFormButton>Submit</button>
<button class="tw-mr-2" type="button" buttonType="secondary" bitButton bitFormButton>Cancel</button>
<button class="tw-mr-2" type="button" buttonType="danger" bitButton bitFormButton [bitAction]="delete">Delete</button>
<button class="tw-mr-2" type="button" buttonType="secondary" bitIconButton="bwi-star" bitFormButton [bitAction]="delete">Delete</button>
</form>`;
@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<void>((resolve, reject) => {
setTimeout(resolve, 2000);
});
};
delete = async () => {
await new Promise<void>((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<ValidationService>,
},
],
}),
],
args: {
disableFormOnLoading: false,
},
} as Meta;
const PromiseTemplate: Story<PromiseExampleComponent> = (args: PromiseExampleComponent) => ({
props: args,
template: `<app-promise-example [disableFormOnLoading]="disableFormOnLoading"></app-promise-example>`,
});
export const UsingPromise = PromiseTemplate.bind({});
const ObservableTemplate: Story<PromiseExampleComponent> = (args: PromiseExampleComponent) => ({
props: args,
template: `<app-observable-example [disableFormOnLoading]="disableFormOnLoading"></app-observable-example>`,
});
export const UsingObservable = ObservableTemplate.bind({});

View File

@ -0,0 +1,3 @@
export * from "./async-actions.module";
export * from "./bit-action.directive";
export * from "./form-button.directive";

View File

@ -0,0 +1,26 @@
import { Meta } from "@storybook/addon-docs";
<Meta title="Component Library/Async Actions/Overview" />
# 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.

View File

@ -0,0 +1,63 @@
import { Meta } from "@storybook/addon-docs";
<Meta title="Component Library/Async Actions/Standalone/Documentation" />
# 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
<button bitButton [bitAction]="handler">Do action</button>
<button bitIconButton="bwi-trash" [bitAction]="handler"></button>`;
```

View File

@ -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 = `
<button bitButton buttonType="primary" [bitAction]="action" class="tw-mr-2">
Perform action
</button>
<button bitIconButton="bwi-trash" buttonType="danger" [bitAction]="action"></button>`;
@Component({
template,
selector: "app-promise-example",
})
class PromiseExampleComponent {
action = async () => {
await new Promise<void>((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<void>((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<ValidationService>,
},
],
}),
],
} as Meta;
const PromiseTemplate: Story<PromiseExampleComponent> = (args: PromiseExampleComponent) => ({
props: args,
template: `<app-promise-example></app-promise-example>`,
});
export const UsingPromise = PromiseTemplate.bind({});
const ObservableTemplate: Story<ObservableExampleComponent> = (
args: ObservableExampleComponent
) => ({
template: `<app-observable-example></app-observable-example>`,
});
export const UsingObservable = ObservableTemplate.bind({});
const RejectedPromiseTemplate: Story<ObservableExampleComponent> = (
args: ObservableExampleComponent
) => ({
template: `<app-rejected-promise-example></app-rejected-promise-example>`,
});
export const RejectedPromise = RejectedPromiseTemplate.bind({});

View File

@ -2,7 +2,10 @@
<span [ngClass]="{ 'tw-invisible': loading }">
<ng-content></ng-content>
</span>
<span class="tw-absolute tw-inset-0" [ngClass]="{ 'tw-invisible': !loading }">
<i class="bwi bwi-spinner bwi-lg bwi-spin tw-align-baseline" aria-hidden="true"></i>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
[ngClass]="{ 'tw-invisible': !loading }"
>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</span>
</span>

View File

@ -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<ButtonTypes, string[]> = {
@ -41,8 +43,9 @@ const buttonStyles: Record<ButtonTypes, string[]> = {
@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",

View File

@ -0,0 +1,15 @@
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }">
<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>
</span>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
[ngClass]="{ 'tw-invisible': !loading }"
>
<i
class="bwi bwi-spinner bwi-spin"
aria-hidden="true"
[ngClass]="{ 'bwi-lg': size === 'default' }"
></i>
</span>
</span>

View File

@ -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<IconButtonStyle, string[]> = {
export type IconButtonType = "contrast" | "main" | "muted" | "primary" | "secondary" | "danger";
const styles: Record<IconButtonType, string[]> = {
contrast: [
"tw-bg-transparent",
"!tw-text-contrast",
@ -10,6 +12,7 @@ const styles: Record<IconButtonStyle, string[]> = {
"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<IconButtonStyle, string[]> = {
"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<IconButtonStyle, string[]> = {
"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<IconButtonStyle, string[]> = {
"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<IconButtonStyle, string[]> = {
"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<IconButtonStyle, string[]> = {
"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<IconButtonSize, string[]> = {
@Component({
selector: "button[bitIconButton]",
template: `<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>`,
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;
}

View File

@ -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<BitIconButtonComponent> = (args: BitIconButtonComponent) => ({
props: args,
props: { ...args, buttonTypes },
template: `
<div class="tw-p-5" [class.tw-bg-primary-500]="buttonType === 'contrast'">
<button
[bitIconButton]="bitIconButton"
[buttonType]="buttonType"
[size]="size"
[disabled]="disabled"
title="Example icon button"
aria-label="Example icon button"></button>
</div>
<table class="tw-border-spacing-2 tw-text-center tw-text-main">
<thead>
<tr>
<td></td>
<td *ngFor="let buttonType of buttonTypes" class="tw-capitalize tw-font-bold tw-p-4"
[class.tw-text-contrast]="buttonType === 'contrast'"
[class.tw-bg-primary-500]="buttonType === 'contrast'">{{buttonType}}</td>
</tr>
</thead>
<tbody>
<tr>
<td class="tw-font-bold tw-p-4 tw-text-left">Default</td>
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="buttonType === 'contrast'">
<button
[bitIconButton]="bitIconButton"
[buttonType]="buttonType"
[size]="size"
title="Example icon button"
aria-label="Example icon button"></button>
</td>
</tr>
<tr>
<td class="tw-font-bold tw-p-4 tw-text-left">Disabled</td>
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="buttonType === 'contrast'">
<button
[bitIconButton]="bitIconButton"
[buttonType]="buttonType"
[size]="size"
disabled
title="Example icon button"
aria-label="Example icon button"></button>
</td>
</tr>
<tr>
<td class="tw-font-bold tw-p-4 tw-text-left">Loading</td>
<td *ngFor="let buttonType of buttonTypes" class="tw-p-2" [class.tw-bg-primary-500]="buttonType === 'contrast'">
<button
[bitIconButton]="bitIconButton"
[buttonType]="buttonType"
[size]="size"
loading="true"
title="Example icon button"
aria-label="Example icon button"></button>
</td>
</tr>
</tbody>
</table>
`,
});
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",
};

View File

@ -1,3 +1,4 @@
export * from "./async-actions";
export * from "./badge";
export * from "./banner";
export * from "./button";

View File

@ -0,0 +1,4 @@
export abstract class ButtonLikeAbstraction {
loading: boolean;
disabled: boolean;
}

View File

@ -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);
});
});

View File

@ -0,0 +1,27 @@
import { from, Observable, of, throwError } from "rxjs";
import { Utils } from "@bitwarden/common/misc/utils";
export type FunctionReturningAwaitable =
| (() => unknown)
| (() => Promise<unknown>)
| (() => Observable<unknown>);
export function functionToObservable(func: FunctionReturningAwaitable): Observable<unknown> {
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);
}