1
0
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:
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,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>

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

View File

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

View File

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

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