1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM-7162] Cipher Form - Item Details (#9758)

* [PM-7162] Fix weird angular error regarding disabled component bit-select

* [PM-7162] Introduce CipherFormConfigService and related types

* [PM-7162] Introduce CipherFormService

* [PM-7162] Introduce the Item Details section component and the CipherFormContainer interface

* [PM-7162] Introduce the CipherForm component

* [PM-7162] Add strongly typed QueryParams to the add-edit-v2.component

* [PM-7162] Export CipherForm from Vault Lib

* [PM-7162] Use the CipherForm in Browser AddEditV2

* [PM-7162] Introduce CipherForm storybook

* [PM-7162] Remove VaultPopupListFilterService dependency from NewItemDropDownV2 component

* [PM-7162] Add support for content projection of attachment button

* [PM-7162] Fix typo

* [PM-7162] Cipher form service cleanup

* [PM-7162] Move readonly collection notice to bit-hint

* [PM-7162] Refactor CipherFormConfig type to enforce required properties with Typescript

* [PM-7162] Fix storybook after config changes

* [PM-7162] Use new add-edit component for clone route
This commit is contained in:
Shane Melton
2024-07-02 13:22:51 -07:00
committed by GitHub
parent 9294a4c47e
commit 17d37ecaeb
26 changed files with 1737 additions and 40 deletions

View File

@@ -0,0 +1,270 @@
import { CommonModule, NgClass } from "@angular/common";
import { Component, DestroyRef, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { concatMap, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import {
CardComponent,
FormFieldModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
SelectItemView,
SelectModule,
TypographyModule,
} from "@bitwarden/components";
import {
CipherFormConfig,
OptionalInitialValues,
} from "../../abstractions/cipher-form-config.service";
import { CipherFormContainer } from "../../cipher-form-container";
@Component({
selector: "vault-item-details-section",
templateUrl: "./item-details-section.component.html",
standalone: true,
imports: [
CardComponent,
SectionComponent,
TypographyModule,
FormFieldModule,
ReactiveFormsModule,
SelectModule,
SectionHeaderComponent,
IconButtonModule,
NgClass,
JslibModule,
CommonModule,
],
})
export class ItemDetailsSectionComponent implements OnInit {
itemDetailsForm = this.formBuilder.group({
name: ["", [Validators.required]],
organizationId: [null],
folderId: [null],
collectionIds: new FormControl([], [Validators.required]),
favorite: [false],
});
/**
* Collection options available for the selected organization.
* @protected
*/
protected collectionOptions: SelectItemView[] = [];
/**
* Collections that are already assigned to the cipher and are read-only. These cannot be removed.
* @protected
*/
protected readOnlyCollections: string[] = [];
protected showCollectionsControl: boolean;
@Input({ required: true })
config: CipherFormConfig;
@Input()
originalCipherView: CipherView;
/**
* Whether the form is in partial edit mode. Only the folder and favorite controls are available.
*/
get partialEdit(): boolean {
return this.config.mode === "partial-edit";
}
get organizations(): Organization[] {
return this.config.organizations;
}
get allowPersonalOwnership() {
return this.config.allowPersonalOwnership;
}
get collections(): CollectionView[] {
return this.config.collections;
}
get initialValues(): OptionalInitialValues | undefined {
return this.config.initialValues;
}
constructor(
private cipherFormContainer: CipherFormContainer,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private destroyRef: DestroyRef,
) {
this.cipherFormContainer.registerChildForm("itemDetails", this.itemDetailsForm);
this.itemDetailsForm.valueChanges
.pipe(
takeUntilDestroyed(),
// getRawValue() because organizationId can be disabled for edit mode
map(() => this.itemDetailsForm.getRawValue()),
)
.subscribe((value) => {
this.cipherFormContainer.patchCipher({
name: value.name,
organizationId: value.organizationId,
folderId: value.folderId,
collectionIds: value.collectionIds?.map((c) => c.id) || [],
favorite: value.favorite,
});
});
}
get favoriteIcon() {
return this.itemDetailsForm.controls.favorite.value ? "bwi-star-f" : "bwi-star";
}
toggleFavorite() {
this.itemDetailsForm.controls.favorite.setValue(!this.itemDetailsForm.controls.favorite.value);
}
get allowOwnershipChange() {
// Do not allow ownership change in edit mode.
if (this.config.mode === "edit") {
return false;
}
// If personal ownership is allowed and there is at least one organization, allow ownership change.
if (this.allowPersonalOwnership) {
return this.organizations.length > 0;
}
// Personal ownership is not allowed, only allow ownership change if there is more than one organization.
return this.organizations.length > 1;
}
get showOwnership() {
return (
this.allowOwnershipChange || (this.organizations.length > 0 && this.config.mode === "edit")
);
}
get defaultOwner() {
return this.allowPersonalOwnership ? null : this.organizations[0].id;
}
async ngOnInit() {
if (!this.allowPersonalOwnership && this.organizations.length === 0) {
throw new Error("No organizations available for ownership.");
}
if (this.originalCipherView) {
await this.initFromExistingCipher();
} else {
this.itemDetailsForm.setValue({
name: "",
organizationId: this.initialValues?.organizationId || this.defaultOwner,
folderId: this.initialValues?.folderId || null,
collectionIds: [],
favorite: false,
});
await this.updateCollectionOptions(this.initialValues?.collectionIds || []);
}
if (!this.allowOwnershipChange) {
this.itemDetailsForm.controls.organizationId.disable();
}
this.itemDetailsForm.controls.organizationId.valueChanges
.pipe(
takeUntilDestroyed(this.destroyRef),
concatMap(async () => {
await this.updateCollectionOptions();
}),
)
.subscribe();
}
private async initFromExistingCipher() {
this.itemDetailsForm.setValue({
name: this.originalCipherView.name,
organizationId: this.originalCipherView.organizationId,
folderId: this.originalCipherView.folderId,
collectionIds: [],
favorite: this.originalCipherView.favorite,
});
// Configure form for clone mode.
if (this.config.mode === "clone") {
this.itemDetailsForm.controls.name.setValue(
this.originalCipherView.name + " - " + this.i18nService.t("clone"),
);
if (!this.allowPersonalOwnership && this.originalCipherView.organizationId == null) {
this.itemDetailsForm.controls.organizationId.setValue(this.defaultOwner);
}
}
await this.updateCollectionOptions(this.originalCipherView.collectionIds as CollectionId[]);
if (this.partialEdit) {
this.itemDetailsForm.disable();
this.itemDetailsForm.controls.favorite.enable();
this.itemDetailsForm.controls.folderId.enable();
} else if (this.config.mode === "edit") {
//
this.readOnlyCollections = this.collections
.filter(
(c) => c.readOnly && this.originalCipherView.collectionIds.includes(c.id as CollectionId),
)
.map((c) => c.name);
}
}
/**
* Updates the collection options based on the selected organization.
* @param startingSelection - Optional starting selection of collectionIds to be automatically selected.
* @private
*/
private async updateCollectionOptions(startingSelection: CollectionId[] = []) {
const orgId = this.itemDetailsForm.controls.organizationId.value as OrganizationId;
const collectionsControl = this.itemDetailsForm.controls.collectionIds;
// No organization selected, disable/hide the collections control.
if (orgId == null) {
this.collectionOptions = [];
collectionsControl.reset();
collectionsControl.disable();
this.showCollectionsControl = false;
return;
}
this.collectionOptions = this.collections
.filter((c) => {
// If partial edit mode, show all org collections because the control is disabled.
return c.organizationId === orgId && (this.partialEdit || !c.readOnly);
})
.map((c) => ({
id: c.id,
name: c.name,
listName: c.name,
labelName: c.name,
}));
collectionsControl.reset();
collectionsControl.enable();
this.showCollectionsControl = true;
// If there is only one collection, select it by default.
if (this.collectionOptions.length === 1) {
collectionsControl.setValue(this.collectionOptions);
return;
}
if (startingSelection.length > 0) {
collectionsControl.setValue(
this.collectionOptions.filter((c) => startingSelection.includes(c.id as CollectionId)),
);
}
}
}