[PM-9869] Create SendFormContainer (#10147)

* Move SendV2component into send-v2 subFolder

* Create SendFormContainer and related services

* Add initial SendFormComponent which uses the SendFormContainer

* Remove AdditionalOptionsSectionComponent which will be added with a future PR

* Add libs/tools/send to root tsconfig

* Register libs/tools/send/send-ui with root jest.config.js

* Register libs/tools/send/send-ui with root tailwind.config.js

* Fix service injection on DefaultSendFormService

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
This commit is contained in:
Daniel James Smith 2024-07-19 21:17:52 +02:00 committed by GitHub
parent beeb0354fd
commit 1320d96cb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 630 additions and 1 deletions

View File

@ -6,6 +6,8 @@ const config: StorybookConfig = {
stories: [ stories: [
"../libs/auth/src/**/*.mdx", "../libs/auth/src/**/*.mdx",
"../libs/auth/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/auth/src/**/*.stories.@(js|jsx|ts|tsx)",
"../libs/tools/send/send-ui/src/**/*.mdx",
"../libs/tools/send/send-ui/src/**/*.stories.@(js|jsx|ts|tsx)",
"../libs/vault/src/**/*.mdx", "../libs/vault/src/**/*.mdx",
"../libs/vault/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/vault/src/**/*.stories.@(js|jsx|ts|tsx)",
"../libs/components/src/**/*.mdx", "../libs/components/src/**/*.mdx",

View File

@ -50,7 +50,7 @@ import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/pass
import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.component"; import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.component";
import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component"; import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component";
import { SendTypeComponent } from "../tools/popup/send/send-type.component"; import { SendTypeComponent } from "../tools/popup/send/send-type.component";
import { SendV2Component } from "../tools/popup/send/send-v2.component"; import { SendV2Component } from "../tools/popup/send-v2/send-v2.component";
import { AboutPageV2Component } from "../tools/popup/settings/about-page/about-page-v2.component"; import { AboutPageV2Component } from "../tools/popup/settings/about-page/about-page-v2.component";
import { AboutPageComponent } from "../tools/popup/settings/about-page/about-page.component"; import { AboutPageComponent } from "../tools/popup/settings/about-page/about-page.component";
import { MoreFromBitwardenPageV2Component } from "../tools/popup/settings/about-page/more-from-bitwarden-page-v2.component"; import { MoreFromBitwardenPageV2Component } from "../tools/popup/settings/about-page/more-from-bitwarden-page-v2.component";

View File

@ -35,6 +35,7 @@ module.exports = {
"<rootDir>/libs/tools/generator/extensions/history/jest.config.js", "<rootDir>/libs/tools/generator/extensions/history/jest.config.js",
"<rootDir>/libs/tools/generator/extensions/legacy/jest.config.js", "<rootDir>/libs/tools/generator/extensions/legacy/jest.config.js",
"<rootDir>/libs/tools/generator/extensions/navigation/jest.config.js", "<rootDir>/libs/tools/generator/extensions/navigation/jest.config.js",
"<rootDir>/libs/tools/send/send-ui/jest.config.js",
"<rootDir>/libs/importer/jest.config.js", "<rootDir>/libs/importer/jest.config.js",
"<rootDir>/libs/platform/jest.config.js", "<rootDir>/libs/platform/jest.config.js",
"<rootDir>/libs/node/jest.config.js", "<rootDir>/libs/node/jest.config.js",

View File

@ -8,4 +8,5 @@ export type CollectionId = Opaque<string, "CollectionId">;
export type ProviderId = Opaque<string, "ProviderId">; export type ProviderId = Opaque<string, "ProviderId">;
export type PolicyId = Opaque<string, "PolicyId">; export type PolicyId = Opaque<string, "PolicyId">;
export type CipherId = Opaque<string, "CipherId">; export type CipherId = Opaque<string, "CipherId">;
export type SendId = Opaque<string, "SendId">;
export type IndexedEntityId = Opaque<string, "IndexedEntityId">; export type IndexedEntityId = Opaque<string, "IndexedEntityId">;

View File

@ -1,2 +1,3 @@
export * from "./icons"; export * from "./icons";
export * from "./send-form";
export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component"; export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component";

View File

@ -0,0 +1,93 @@
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { SendId } from "@bitwarden/common/types/guid";
/**
* The mode of the add/edit form.
* - `add` - The form is creating a new send.
* - `edit` - The form is editing an existing send.
* - `partial-edit` - The form is editing an existing send, but only the favorite/folder fields
*/
export type SendFormMode = "add" | "edit" | "partial-edit";
/**
* Base configuration object for the send form. Includes all common fields.
*/
type BaseSendFormConfig = {
/**
* The mode of the form.
*/
mode: SendFormMode;
/**
* The type of send to create/edit.
*/
sendType: SendType;
/**
* Flag to indicate if the user is allowed to create sends. If false, configuration must
* supply a list of organizations that the user can create sends in.
*/
areSendsAllowed: boolean;
/**
* The original send that is being edited or cloned. This can be undefined when creating a new send.
*/
originalSend?: Send;
};
/**
* Configuration object for the send form when editing an existing send.
*/
type ExistingSendConfig = BaseSendFormConfig & {
mode: "edit" | "partial-edit";
originalSend: Send;
};
/**
* Configuration object for the send form when creating a completely new send.
*/
type CreateNewSendConfig = BaseSendFormConfig & {
mode: "add";
};
type CombinedAddEditConfig = ExistingSendConfig | CreateNewSendConfig;
/**
* Configuration object for the send form when personal ownership is allowed.
*/
type SendsAllowed = CombinedAddEditConfig & {
areSendsAllowed: true;
};
/**
* Configuration object for the send form when Sends are not allowed by an organization.
* Organizations must be provided.
*/
type SendsNotAllowed = CombinedAddEditConfig & {
areSendsAllowed: false;
};
/**
* Configuration object for the send form.
* Determines the behavior of the form and the controls that are displayed/enabled.
*/
export type SendFormConfig = SendsAllowed | SendsNotAllowed;
/**
* Service responsible for building the configuration object for the send form.
*/
export abstract class SendFormConfigService {
/**
* Builds the configuration for the send form using the specified mode, sendId, and sendType.
* The other configuration fields will be fetched from their respective services.
* @param mode
* @param sendId
* @param sendType
*/
abstract buildConfig(
mode: SendFormMode,
sendId?: SendId,
sendType?: SendType,
): Promise<SendFormConfig>;
}

View File

@ -0,0 +1,26 @@
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendFormConfig } from "./send-form-config.service";
/**
* Service to save the send using the correct endpoint(s) and encapsulating the logic for decrypting the send.
*
* This service should only be used internally by the SendFormComponent.
*/
export abstract class SendFormService {
/**
* Helper to decrypt a send and avoid the need to call the send service directly.
* (useful for mocking tests/storybook).
*/
abstract decryptSend(send: Send): Promise<SendView>;
/**
* Saves the new or modified send with the server.
*/
abstract saveSend(
send: SendView,
file: File | ArrayBuffer,
config: SendFormConfig,
): Promise<SendView>;
}

View File

@ -0,0 +1,4 @@
<form [id]="formId" [formGroup]="sendForm" [bitSubmit]="submit">
<!-- TODO: Should we show a loading spinner here? Or emit a ready event for the container to handle loading state -->
<ng-container *ngIf="!loading"> </ng-container>
</form>

View File

@ -0,0 +1,210 @@
import { NgIf } from "@angular/common";
import {
AfterViewInit,
Component,
DestroyRef,
EventEmitter,
forwardRef,
inject,
Input,
OnChanges,
OnInit,
Output,
ViewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import {
AsyncActionsModule,
BitSubmitDirective,
ButtonComponent,
CardComponent,
FormFieldModule,
ItemModule,
SectionComponent,
SelectModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { SendFormConfig } from "../abstractions/send-form-config.service";
import { SendFormService } from "../abstractions/send-form.service";
import { SendForm, SendFormContainer } from "../send-form-container";
@Component({
selector: "tools-send-form",
templateUrl: "./send-form.component.html",
standalone: true,
providers: [
{
provide: SendFormContainer,
useExisting: forwardRef(() => SendFormComponent),
},
],
imports: [
AsyncActionsModule,
CardComponent,
SectionComponent,
TypographyModule,
ItemModule,
FormFieldModule,
ReactiveFormsModule,
SelectModule,
NgIf,
],
})
export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, SendFormContainer {
@ViewChild(BitSubmitDirective)
private bitSubmit: BitSubmitDirective;
private destroyRef = inject(DestroyRef);
private _firstInitialized = false;
/**
* The form ID to use for the form. Used to connect it to a submit button.
*/
@Input({ required: true }) formId: string;
/**
* The configuration for the add/edit form. Used to determine which controls are shown and what values are available.
*/
@Input({ required: true }) config: SendFormConfig;
/**
* Optional submit button that will be disabled or marked as loading when the form is submitting.
*/
@Input()
submitBtn?: ButtonComponent;
/**
* Event emitted when the send is saved successfully.
*/
@Output() sendSaved = new EventEmitter<SendView>();
/**
* The original send being edited or cloned. Null for add mode.
*/
originalSendView: SendView | null;
/**
* The form group for the send. Starts empty and is populated by child components via the `registerChildForm` method.
* @protected
*/
protected sendForm = this.formBuilder.group<SendForm>({});
/**
* The value of the updated send. Starts as a new send and is updated
* by child components via the `patchSend` method.
* @protected
*/
protected updatedSendView: SendView | null;
protected loading: boolean = true;
SendType = SendType;
ngAfterViewInit(): void {
if (this.submitBtn) {
this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => {
this.submitBtn.loading = loading;
});
this.bitSubmit.disabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((disabled) => {
this.submitBtn.disabled = disabled;
});
}
}
/**
* Registers a child form group with the parent form group. Used by child components to add their form groups to
* the parent form for validation.
* @param name - The name of the form group.
* @param group - The form group to add.
*/
registerChildForm<K extends keyof SendForm>(
name: K,
group: Exclude<SendForm[K], undefined>,
): void {
this.sendForm.setControl(name, group);
}
/**
* Patches the updated send with the provided partial senbd. Used by child components to update the send
* as their form values change.
* @param send
*/
patchSend(send: Partial<SendView>): void {
this.updatedSendView = Object.assign(this.updatedSendView, send);
}
/**
* We need to re-initialize the form when the config is updated.
*/
async ngOnChanges() {
// Avoid re-initializing the form on the first change detection cycle.
if (this._firstInitialized) {
await this.init();
}
}
async ngOnInit() {
await this.init();
this._firstInitialized = true;
}
async init() {
this.loading = true;
this.updatedSendView = new SendView();
this.originalSendView = null;
this.sendForm.reset();
if (this.config == null) {
return;
}
if (this.config.mode !== "add") {
if (this.config.originalSend == null) {
throw new Error("Original send is required for edit or clone mode");
}
this.originalSendView = await this.addEditFormService.decryptSend(this.config.originalSend);
this.updatedSendView = Object.assign(this.updatedSendView, this.originalSendView);
} else {
this.updatedSendView.type = this.config.sendType;
}
this.loading = false;
}
constructor(
private formBuilder: FormBuilder,
private addEditFormService: SendFormService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
submit = async () => {
if (this.sendForm.invalid) {
this.sendForm.markAllAsTouched();
return;
}
// TODO: Add file handling
await this.addEditFormService.saveSend(this.updatedSendView, null, this.config);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
this.config.mode === "edit" || this.config.mode === "partial-edit"
? "editedItem"
: "addedItem",
),
});
this.sendSaved.emit(this.updatedSendView);
};
}

View File

@ -0,0 +1,7 @@
export { SendFormModule } from "./send-form.module";
export {
SendFormConfigService,
SendFormConfig,
SendFormMode,
} from "./abstractions/send-form-config.service";
export { DefaultSendFormConfigService } from "./services/default-send-form-config.service";

View File

@ -0,0 +1,36 @@
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendFormConfig } from "./abstractions/send-form-config.service";
/**
* The complete form for a send. Includes all the sub-forms from their respective section components.
* TODO: Add additional form sections as they are implemented.
*/
export type SendForm = object;
/**
* A container for the {@link SendForm} that allows for registration of child form groups and patching of the send
* to be updated/created. Child form components inject this container in order to register themselves with the parent form
* and access configuration options.
*
* This is an alternative to passing the form groups down through the component tree via @Inputs() and form updates via
* @Outputs(). It allows child forms to define their own structure and validation rules, while still being able to
* update the parent send.
*/
export abstract class SendFormContainer {
/**
* The configuration for the send form.
*/
readonly config: SendFormConfig;
/**
* The original send that is being edited/cloned. Used to pre-populate the form and compare changes.
*/
readonly originalSendView: SendView | null;
abstract registerChildForm<K extends keyof SendForm>(
name: K,
group: Exclude<SendForm[K], undefined>,
): void;
abstract patchSend(send: Partial<SendView>): void;
}

View File

@ -0,0 +1,17 @@
import { Controls, Meta, Primary } from "@storybook/addon-docs";
import * as stories from "./send-form.stories";
<Meta of={stories} />
# Send Form
The send form is a re-usable form component that can be used to create, update, and clone sends. It
is configured via a `SendFormConfig` object that is passed to the component as a prop. The
`SendFormConfig` object can be created manually, or a `SendFormConfigService` can be used to create
it. A default implementation of the `SendFormConfigService` exists in the `@bitwarden/send-ui`
library.
<Primary />
<Controls include={["config", "submitBtn"]} />

View File

@ -0,0 +1,17 @@
import { NgModule } from "@angular/core";
import { SendFormService } from "./abstractions/send-form.service";
import { SendFormComponent } from "./components/send-form.component";
import { DefaultSendFormService } from "./services/default-send-form.service";
@NgModule({
imports: [SendFormComponent],
providers: [
{
provide: SendFormService,
useClass: DefaultSendFormService,
},
],
exports: [SendFormComponent],
})
export class SendFormModule {}

View File

@ -0,0 +1,132 @@
import { importProvidersFrom } from "@angular/core";
import { action } from "@storybook/addon-actions";
import {
applicationConfig,
componentWrapperDecorator,
Meta,
moduleMetadata,
StoryObj,
} from "@storybook/angular";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { AsyncActionsModule, ButtonModule, ToastService } from "@bitwarden/components";
import { SendFormConfig } from "@bitwarden/send-ui";
import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests";
import { SendFormService } from "./abstractions/send-form.service";
import { SendFormComponent } from "./components/send-form.component";
import { SendFormModule } from "./send-form.module";
const defaultConfig: SendFormConfig = {
mode: "add",
sendType: SendType.Text,
areSendsAllowed: true,
originalSend: {
id: "123",
name: "Test Send",
notes: "Example notes",
} as unknown as Send,
};
class TestAddEditFormService implements SendFormService {
decryptSend(): Promise<SendView> {
return Promise.resolve(defaultConfig.originalSend as any);
}
async saveSend(send: SendView, file: File | ArrayBuffer): Promise<SendView> {
await new Promise((resolve) => setTimeout(resolve, 1000));
return send;
}
}
const actionsData = {
onSave: action("onSave"),
};
export default {
title: "Tools/Send Form",
component: SendFormComponent,
decorators: [
moduleMetadata({
imports: [SendFormModule, AsyncActionsModule, ButtonModule],
providers: [
{
provide: SendFormService,
useClass: TestAddEditFormService,
},
{
provide: ToastService,
useValue: {
showToast: action("showToast"),
},
},
],
}),
componentWrapperDecorator(
(story) => `<div class="tw-bg-background-alt tw-text-main tw-border">${story}</div>`,
),
applicationConfig({
providers: [importProvidersFrom(PreloadedEnglishI18nModule)],
}),
],
args: {
config: defaultConfig,
},
argTypes: {
config: {
description: "The configuration object for the form.",
},
},
} as Meta;
type Story = StoryObj<SendFormComponent>;
export const Default: Story = {
render: (args) => {
return {
props: {
onSave: actionsData.onSave,
...args,
},
template: /*html*/ `
<tools-send-form [config]="config" (cipherSaved)="onSave($event)" formId="test-form" [submitBtn]="submitBtn"></tools-send-form>
<button type="submit" form="test-form" bitButton buttonType="primary" #submitBtn>Submit</button>
`,
};
},
};
export const Edit: Story = {
...Default,
args: {
config: {
...defaultConfig,
mode: "edit",
originalSend: defaultConfig.originalSend,
},
},
};
export const PartialEdit: Story = {
...Default,
args: {
config: {
...defaultConfig,
mode: "partial-edit",
originalSend: defaultConfig.originalSend,
},
},
};
export const SendsHaveBeenDisabledByPolicy: Story = {
...Default,
args: {
config: {
...defaultConfig,
mode: "add",
areSendsAllowed: false,
originalSend: defaultConfig.originalSend,
},
},
};

View File

@ -0,0 +1,51 @@
import { inject, Injectable } from "@angular/core";
import { combineLatest, firstValueFrom, map } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SendId } from "@bitwarden/common/types/guid";
import {
SendFormConfig,
SendFormConfigService,
SendFormMode,
} from "../abstractions/send-form-config.service";
/**
* Default implementation of the `SendFormConfigService`.
*/
@Injectable()
export class DefaultSendFormConfigService implements SendFormConfigService {
private policyService: PolicyService = inject(PolicyService);
private sendService: SendService = inject(SendService);
async buildConfig(
mode: SendFormMode,
sendId?: SendId,
sendType?: SendType,
): Promise<SendFormConfig> {
const [areSendsAllowed, send] = await firstValueFrom(
combineLatest([this.areSendsEnabled$, this.getSend(sendId)]),
);
return {
mode,
sendType: sendType,
areSendsAllowed,
originalSend: send,
};
}
private areSendsEnabled$ = this.policyService
.policyAppliesToActiveUser$(PolicyType.DisableSend)
.pipe(map((p) => !p));
private getSend(id?: SendId) {
if (id == null) {
return Promise.resolve(null);
}
return this.sendService.get$(id);
}
}

View File

@ -0,0 +1,29 @@
import { inject, Injectable } from "@angular/core";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SendFormConfig } from "../abstractions/send-form-config.service";
import { SendFormService } from "../abstractions/send-form.service";
@Injectable()
export class DefaultSendFormService implements SendFormService {
private sendApiService: SendApiService = inject(SendApiService);
private sendService = inject(SendService);
async decryptSend(send: Send): Promise<SendView> {
return await send.decrypt();
}
async saveSend(
send: SendView,
file: File | ArrayBuffer,
config: SendFormConfig,
): Promise<SendView> {
const sendData = await this.sendService.encrypt(send, file, send.password, null);
const savedSend = await this.sendApiService.save(sendData);
return await savedSend.decrypt();
}
}

View File

@ -7,6 +7,7 @@ config.content = [
"./libs/auth/src/**/*.{html,ts,mdx}", "./libs/auth/src/**/*.{html,ts,mdx}",
"./libs/billing/src/**/*.{html,ts,mdx}", "./libs/billing/src/**/*.{html,ts,mdx}",
"./libs/platform/src/**/*.{html,ts,mdx}", "./libs/platform/src/**/*.{html,ts,mdx}",
"./libs/tools/send/send-ui/src/*.{html,ts,mdx}",
"./libs/vault/src/**/*.{html,ts,mdx}", "./libs/vault/src/**/*.{html,ts,mdx}",
"./apps/web/src/**/*.{html,ts,mdx}", "./apps/web/src/**/*.{html,ts,mdx}",
"./bitwarden_license/bit-web/src/**/*.{html,ts,mdx}", "./bitwarden_license/bit-web/src/**/*.{html,ts,mdx}",

View File

@ -49,6 +49,7 @@
"apps/web/src/**/*", "apps/web/src/**/*",
"apps/browser/src/**/*", "apps/browser/src/**/*",
"libs/*/src/**/*", "libs/*/src/**/*",
"libs/tools/send/**/src/**/*",
"bitwarden_license/bit-web/src/**/*", "bitwarden_license/bit-web/src/**/*",
"bitwarden_license/bit-common/src/**/*" "bitwarden_license/bit-common/src/**/*"
], ],