mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +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:
@@ -0,0 +1,14 @@
|
||||
<form [id]="formId" [formGroup]="cipherForm" [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">
|
||||
<vault-item-details-section
|
||||
[config]="config"
|
||||
[originalCipherView]="originalCipherView"
|
||||
></vault-item-details-section>
|
||||
|
||||
<!-- Attachments are only available for existing ciphers -->
|
||||
<ng-container *ngIf="config.mode == 'edit'">
|
||||
<ng-content select="[slot=attachment-button]"></ng-content>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</form>
|
||||
212
libs/vault/src/cipher-form/components/cipher-form.component.ts
Normal file
212
libs/vault/src/cipher-form/components/cipher-form.component.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
BitSubmitDirective,
|
||||
ButtonComponent,
|
||||
CardComponent,
|
||||
FormFieldModule,
|
||||
ItemModule,
|
||||
SectionComponent,
|
||||
SelectModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { CipherFormConfig } from "../abstractions/cipher-form-config.service";
|
||||
import { CipherFormService } from "../abstractions/cipher-form.service";
|
||||
import { CipherForm, CipherFormContainer } from "../cipher-form-container";
|
||||
|
||||
import { ItemDetailsSectionComponent } from "./item-details/item-details-section.component";
|
||||
|
||||
@Component({
|
||||
selector: "vault-cipher-form",
|
||||
templateUrl: "./cipher-form.component.html",
|
||||
standalone: true,
|
||||
providers: [
|
||||
{
|
||||
provide: CipherFormContainer,
|
||||
useExisting: forwardRef(() => CipherFormComponent),
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
CardComponent,
|
||||
SectionComponent,
|
||||
TypographyModule,
|
||||
ItemModule,
|
||||
FormFieldModule,
|
||||
ReactiveFormsModule,
|
||||
SelectModule,
|
||||
ItemDetailsSectionComponent,
|
||||
NgIf,
|
||||
],
|
||||
})
|
||||
export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, CipherFormContainer {
|
||||
@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: CipherFormConfig;
|
||||
|
||||
/**
|
||||
* Optional submit button that will be disabled or marked as loading when the form is submitting.
|
||||
*/
|
||||
@Input()
|
||||
submitBtn?: ButtonComponent;
|
||||
|
||||
/**
|
||||
* Event emitted when the cipher is saved successfully.
|
||||
*/
|
||||
@Output() cipherSaved = new EventEmitter<CipherView>();
|
||||
|
||||
/**
|
||||
* The form group for the cipher. Starts empty and is populated by child components via the `registerChildForm` method.
|
||||
* @protected
|
||||
*/
|
||||
protected cipherForm = this.formBuilder.group<CipherForm>({});
|
||||
|
||||
/**
|
||||
* The original cipher being edited or cloned. Null for add mode.
|
||||
* @protected
|
||||
*/
|
||||
protected originalCipherView: CipherView | null;
|
||||
|
||||
/**
|
||||
* The value of the updated cipher. Starts as a new cipher (or clone of originalCipher) and is updated
|
||||
* by child components via the `patchCipher` method.
|
||||
* @protected
|
||||
*/
|
||||
protected updatedCipherView: CipherView | null;
|
||||
protected loading: boolean = true;
|
||||
|
||||
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 CipherForm>(
|
||||
name: K,
|
||||
group: Exclude<CipherForm[K], undefined>,
|
||||
): void {
|
||||
this.cipherForm.setControl(name, group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the updated cipher with the provided partial cipher. Used by child components to update the cipher
|
||||
* as their form values change.
|
||||
* @param cipher
|
||||
*/
|
||||
patchCipher(cipher: Partial<CipherView>): void {
|
||||
this.updatedCipherView = Object.assign(this.updatedCipherView, cipher);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.updatedCipherView = new CipherView();
|
||||
this.originalCipherView = null;
|
||||
this.cipherForm.reset();
|
||||
|
||||
if (this.config == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.mode !== "add") {
|
||||
if (this.config.originalCipher == null) {
|
||||
throw new Error("Original cipher is required for edit or clone mode");
|
||||
}
|
||||
|
||||
this.originalCipherView = await this.addEditFormService.decryptCipher(
|
||||
this.config.originalCipher,
|
||||
);
|
||||
|
||||
this.updatedCipherView = Object.assign(this.updatedCipherView, this.originalCipherView);
|
||||
} else {
|
||||
this.updatedCipherView.type = this.config.cipherType;
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private addEditFormService: CipherFormService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
submit = async () => {
|
||||
if (this.cipherForm.invalid) {
|
||||
this.cipherForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.addEditFormService.saveCipher(this.updatedCipherView, 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.cipherSaved.emit(this.updatedCipherView);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<bit-section [formGroup]="itemDetailsForm">
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h5">{{ "itemDetails" | i18n }}</h2>
|
||||
<button
|
||||
slot="end"
|
||||
type="button"
|
||||
size="small"
|
||||
[bitIconButton]="favoriteIcon"
|
||||
role="checkbox"
|
||||
[attr.aria-checked]="itemDetailsForm.value.favorite"
|
||||
[appA11yTitle]="'favorite' | i18n"
|
||||
(click)="toggleFavorite()"
|
||||
></button>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "itemName" | i18n }}</bit-label>
|
||||
<input bitInput formControlName="name" />
|
||||
</bit-form-field>
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-1">
|
||||
<bit-form-field class="tw-flex-1" *ngIf="showOwnership">
|
||||
<bit-label>{{ "owner" | i18n }}</bit-label>
|
||||
<bit-select formControlName="organizationId">
|
||||
<bit-option
|
||||
*ngIf="allowPersonalOwnership"
|
||||
[value]="null"
|
||||
[label]="'selfOwnershipLabel' | i18n"
|
||||
></bit-option>
|
||||
<bit-option
|
||||
*ngFor="let org of config.organizations"
|
||||
[value]="org.id"
|
||||
[label]="org.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
<bit-form-field class="tw-flex-1">
|
||||
<bit-label>{{ "folder" | i18n }}</bit-label>
|
||||
<bit-select formControlName="folderId">
|
||||
<bit-option
|
||||
*ngFor="let folder of config.folders"
|
||||
[value]="folder.id"
|
||||
[label]="folder.name"
|
||||
></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<ng-container *ngIf="showCollectionsControl">
|
||||
<bit-form-field class="tw-w-full">
|
||||
<bit-label>{{ "collections" | i18n }}</bit-label>
|
||||
<bit-multi-select
|
||||
class="tw-w-full"
|
||||
formControlName="collectionIds"
|
||||
[baseItems]="collectionOptions"
|
||||
></bit-multi-select>
|
||||
<bit-hint *ngIf="readOnlyCollections.length > 0" data-testid="view-only-hint">
|
||||
{{ "cannotRemoveViewOnlyCollections" | i18n: readOnlyCollections.join(", ") }}
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
</ng-container>
|
||||
</bit-card>
|
||||
</bit-section>
|
||||
@@ -0,0 +1,355 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||
import { ReactiveFormsModule } from "@angular/forms";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||
|
||||
import { CipherFormConfig } from "../../abstractions/cipher-form-config.service";
|
||||
import { CipherFormContainer } from "../../cipher-form-container";
|
||||
|
||||
import { ItemDetailsSectionComponent } from "./item-details-section.component";
|
||||
|
||||
describe("ItemDetailsSectionComponent", () => {
|
||||
let component: ItemDetailsSectionComponent;
|
||||
let fixture: ComponentFixture<ItemDetailsSectionComponent>;
|
||||
let cipherFormProvider: MockProxy<CipherFormContainer>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
cipherFormProvider = mock<CipherFormContainer>();
|
||||
i18nService = mock<I18nService>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule],
|
||||
providers: [
|
||||
{ provide: CipherFormContainer, useValue: cipherFormProvider },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ItemDetailsSectionComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.config = {
|
||||
collections: [],
|
||||
organizations: [],
|
||||
folders: [],
|
||||
} as CipherFormConfig;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
it("should throw an error if no organizations are available for ownership and personal ownership is not allowed", async () => {
|
||||
component.config.allowPersonalOwnership = false;
|
||||
component.config.organizations = [];
|
||||
await expect(component.ngOnInit()).rejects.toThrow(
|
||||
"No organizations available for ownership.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should initialize form with default values if no originalCipher is provided", fakeAsync(async () => {
|
||||
component.config.allowPersonalOwnership = true;
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
await component.ngOnInit();
|
||||
tick();
|
||||
expect(cipherFormProvider.patchCipher).toHaveBeenLastCalledWith({
|
||||
name: "",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
collectionIds: [],
|
||||
favorite: false,
|
||||
});
|
||||
}));
|
||||
|
||||
it("should initialize form with values from originalCipher if provided", fakeAsync(async () => {
|
||||
component.config.allowPersonalOwnership = true;
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
component.config.collections = [
|
||||
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
|
||||
];
|
||||
component.originalCipherView = {
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col1"],
|
||||
favorite: true,
|
||||
} as CipherView;
|
||||
|
||||
await component.ngOnInit();
|
||||
tick();
|
||||
|
||||
expect(cipherFormProvider.patchCipher).toHaveBeenLastCalledWith({
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col1"],
|
||||
favorite: true,
|
||||
});
|
||||
}));
|
||||
|
||||
it("should disable organizationId control if ownership change is not allowed", async () => {
|
||||
component.config.allowPersonalOwnership = false;
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(false);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.itemDetailsForm.controls.organizationId.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleFavorite", () => {
|
||||
it("should toggle the favorite control value", () => {
|
||||
component.itemDetailsForm.controls.favorite.setValue(false);
|
||||
component.toggleFavorite();
|
||||
expect(component.itemDetailsForm.controls.favorite.value).toBe(true);
|
||||
component.toggleFavorite();
|
||||
expect(component.itemDetailsForm.controls.favorite.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("favoriteIcon", () => {
|
||||
it("should return the correct icon based on favorite value", () => {
|
||||
component.itemDetailsForm.controls.favorite.setValue(false);
|
||||
expect(component.favoriteIcon).toBe("bwi-star");
|
||||
component.itemDetailsForm.controls.favorite.setValue(true);
|
||||
expect(component.favoriteIcon).toBe("bwi-star-f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowOwnershipChange", () => {
|
||||
it("should not allow ownership change in edit mode", () => {
|
||||
component.config.mode = "edit";
|
||||
expect(component.allowOwnershipChange).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow ownership change if personal ownership is allowed and there is at least one organization", () => {
|
||||
component.config.allowPersonalOwnership = true;
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
expect(component.allowOwnershipChange).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow ownership change if personal ownership is not allowed but there is more than one organization", () => {
|
||||
component.config.allowPersonalOwnership = false;
|
||||
component.config.organizations = [
|
||||
{ id: "org1" } as Organization,
|
||||
{ id: "org2" } as Organization,
|
||||
];
|
||||
expect(component.allowOwnershipChange).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultOwner", () => {
|
||||
it("should return null if personal ownership is allowed", () => {
|
||||
component.config.allowPersonalOwnership = true;
|
||||
expect(component.defaultOwner).toBeNull();
|
||||
});
|
||||
|
||||
it("should return the first organization id if personal ownership is not allowed", () => {
|
||||
component.config.allowPersonalOwnership = false;
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
expect(component.defaultOwner).toBe("org1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("showOwnership", () => {
|
||||
it("should return true if ownership change is allowed or in edit mode with at least one organization", () => {
|
||||
jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(true);
|
||||
expect(component.showOwnership).toBe(true);
|
||||
|
||||
jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(false);
|
||||
component.config.mode = "edit";
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
expect(component.showOwnership).toBe(true);
|
||||
});
|
||||
|
||||
it("should hide the ownership control if showOwnership is false", async () => {
|
||||
jest.spyOn(component, "showOwnership", "get").mockReturnValue(false);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const ownershipControl = fixture.nativeElement.querySelector(
|
||||
"bit-select[formcontrolname='organizationId']",
|
||||
);
|
||||
expect(ownershipControl).toBeNull();
|
||||
});
|
||||
|
||||
it("should show the ownership control if showOwnership is true", async () => {
|
||||
jest.spyOn(component, "allowOwnershipChange", "get").mockReturnValue(true);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const ownershipControl = fixture.nativeElement.querySelector(
|
||||
"bit-select[formcontrolname='organizationId']",
|
||||
);
|
||||
expect(ownershipControl).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloneMode", () => {
|
||||
it("should append '- Clone' to the title if in clone mode", async () => {
|
||||
component.config.mode = "clone";
|
||||
component.config.allowPersonalOwnership = true;
|
||||
component.originalCipherView = {
|
||||
name: "cipher1",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
collectionIds: null,
|
||||
favorite: false,
|
||||
} as CipherView;
|
||||
|
||||
i18nService.t.calledWith("clone").mockReturnValue("Clone");
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.itemDetailsForm.controls.name.value).toBe("cipher1 - Clone");
|
||||
});
|
||||
|
||||
it("should select the first organization if personal ownership is not allowed", async () => {
|
||||
component.config.mode = "clone";
|
||||
component.config.allowPersonalOwnership = false;
|
||||
component.config.organizations = [
|
||||
{ id: "org1" } as Organization,
|
||||
{ id: "org2" } as Organization,
|
||||
];
|
||||
component.originalCipherView = {
|
||||
name: "cipher1",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
collectionIds: [],
|
||||
favorite: false,
|
||||
} as CipherView;
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component.itemDetailsForm.controls.organizationId.value).toBe("org1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectionOptions", () => {
|
||||
it("should reset and disable/hide collections control when no organization is selected", async () => {
|
||||
component.config.allowPersonalOwnership = true;
|
||||
component.itemDetailsForm.controls.organizationId.setValue(null);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const collectionSelect = fixture.nativeElement.querySelector(
|
||||
"bit-multi-select[formcontrolname='collectionIds']",
|
||||
);
|
||||
|
||||
expect(component.itemDetailsForm.controls.collectionIds.value).toEqual(null);
|
||||
expect(component.itemDetailsForm.controls.collectionIds.disabled).toBe(true);
|
||||
expect(collectionSelect).toBeNull();
|
||||
});
|
||||
|
||||
it("should enable/show collection control when an organization is selected", async () => {
|
||||
component.config.allowPersonalOwnership = true;
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
component.config.collections = [
|
||||
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
|
||||
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
|
||||
];
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
component.itemDetailsForm.controls.organizationId.setValue("org1");
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const collectionSelect = fixture.nativeElement.querySelector(
|
||||
"bit-multi-select[formcontrolname='collectionIds']",
|
||||
);
|
||||
|
||||
expect(component.itemDetailsForm.controls.collectionIds.enabled).toBe(true);
|
||||
expect(collectionSelect).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should set collectionIds to originalCipher collections on first load", async () => {
|
||||
component.config.mode = "clone";
|
||||
component.originalCipherView = {
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col1", "col2"],
|
||||
favorite: true,
|
||||
} as CipherView;
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
component.config.collections = [
|
||||
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
|
||||
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
|
||||
{ id: "col3", name: "Collection 3", organizationId: "org1" } as CollectionView,
|
||||
];
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(cipherFormProvider.patchCipher).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
collectionIds: ["col1", "col2"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should automatically select the first collection if only one is available", async () => {
|
||||
component.config.allowPersonalOwnership = true;
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
component.config.collections = [
|
||||
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
|
||||
];
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
component.itemDetailsForm.controls.organizationId.setValue("org1");
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component.itemDetailsForm.controls.collectionIds.value).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "col1" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("should show readonly hint if readonly collections are present", async () => {
|
||||
component.config.mode = "edit";
|
||||
component.originalCipherView = {
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col1", "col2", "col3"],
|
||||
favorite: true,
|
||||
} as CipherView;
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
component.config.collections = [
|
||||
{ id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView,
|
||||
{ id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView,
|
||||
{
|
||||
id: "col3",
|
||||
name: "Collection 3",
|
||||
organizationId: "org1",
|
||||
readOnly: true,
|
||||
} as CollectionView,
|
||||
];
|
||||
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
|
||||
const collectionHint = fixture.nativeElement.querySelector(
|
||||
"bit-hint[data-testid='view-only-hint']",
|
||||
);
|
||||
|
||||
expect(collectionHint).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user