mirror of
https://github.com/bitwarden/browser
synced 2025-12-13 14:53:33 +00:00
[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:
14
libs/components/src/async-actions/async-actions.module.ts
Normal file
14
libs/components/src/async-actions/async-actions.module.ts
Normal 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 {}
|
||||
58
libs/components/src/async-actions/bit-action.directive.ts
Normal file
58
libs/components/src/async-actions/bit-action.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
83
libs/components/src/async-actions/bit-submit.directive.ts
Normal file
83
libs/components/src/async-actions/bit-submit.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
58
libs/components/src/async-actions/form-button.directive.ts
Normal file
58
libs/components/src/async-actions/form-button.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
114
libs/components/src/async-actions/in-forms.stories.mdx
Normal file
114
libs/components/src/async-actions/in-forms.stories.mdx
Normal 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>
|
||||
```
|
||||
156
libs/components/src/async-actions/in-forms.stories.ts
Normal file
156
libs/components/src/async-actions/in-forms.stories.ts
Normal 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({});
|
||||
3
libs/components/src/async-actions/index.ts
Normal file
3
libs/components/src/async-actions/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./async-actions.module";
|
||||
export * from "./bit-action.directive";
|
||||
export * from "./form-button.directive";
|
||||
26
libs/components/src/async-actions/overview.stories.mdx
Normal file
26
libs/components/src/async-actions/overview.stories.mdx
Normal 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.
|
||||
63
libs/components/src/async-actions/standalone.stories.mdx
Normal file
63
libs/components/src/async-actions/standalone.stories.mdx
Normal 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>`;
|
||||
```
|
||||
97
libs/components/src/async-actions/standalone.stories.ts
Normal file
97
libs/components/src/async-actions/standalone.stories.ts
Normal 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({});
|
||||
Reference in New Issue
Block a user