From 84ee01caeea3b122b2d54b55d73725c2df9d1d93 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:55:09 -0500 Subject: [PATCH] [PM-7103] Identity View (#10240) * make headings of v2 view component lowercase * initial add of view identity sections * adding identification fields * add contact information section * add copy ability to all identity fields * add visibility toggle for passport and ssn * add testids for all identity fields * add test-ids to visibility toggles * refactor visibility methods to be called in `ngOnInit` rather than on each render * replace `disabled` with `readonly` --- apps/browser/src/_locales/en/messages.json | 15 ++ .../cipher-view/cipher-view.component.html | 4 + .../src/cipher-view/cipher-view.component.ts | 2 + .../view-identity-sections.component.html | 161 ++++++++++++++ .../view-identity-sections.component.spec.ts | 200 ++++++++++++++++++ .../view-identity-sections.component.ts | 72 +++++++ 6 files changed, 454 insertions(+) create mode 100644 libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html create mode 100644 libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.spec.ts create mode 100644 libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 8b9ce12afd..432479aa80 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -116,6 +116,21 @@ "copySecurityCode": { "message": "Copy security code" }, + "copyName": { + "message": "Copy name" + }, + "copyCompany": { + "message": "Copy company" + }, + "copySSN": { + "message": "Copy Social Security number" + }, + "copyPassportNumber": { + "message": "Copy passport number" + }, + "copyLicenseNumber": { + "message": "Copy license number" + }, "autoFill": { "message": "Autofill" }, diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index 5ef7f12341..1463cbd731 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -8,6 +8,10 @@ > + + + + diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 6e208d6f28..7e72424e1f 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -22,6 +22,7 @@ import { AttachmentsV2ViewComponent } from "./attachments/attachments-v2-view.co import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.component"; import { ItemDetailsV2Component } from "./item-details/item-details-v2.component"; import { ItemHistoryV2Component } from "./item-history/item-history-v2.component"; +import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-identity-sections.component"; @Component({ selector: "app-cipher-view", @@ -39,6 +40,7 @@ import { ItemHistoryV2Component } from "./item-history/item-history-v2.component AttachmentsV2ViewComponent, ItemHistoryV2Component, CustomFieldV2Component, + ViewIdentitySectionsComponent, ], }) export class CipherViewComponent implements OnInit { diff --git a/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html new file mode 100644 index 0000000000..742f3a5efe --- /dev/null +++ b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.html @@ -0,0 +1,161 @@ + + +

{{ "personalDetails" | i18n }}

+
+ + + + {{ "name" | i18n }} + + + + + {{ "username" | i18n }} + + + + + {{ "company" | i18n }} + + + + +
+ + + +

{{ "identification" | i18n }}

+
+ + + + {{ "ssn" | i18n }} + + + + + + {{ "passportNumber" | i18n }} + + + + + + {{ "licenseNumber" | i18n }} + + + + +
+ + + +

{{ "contactInfo" | i18n }}

+
+ + + + {{ "email" | i18n }} + + + + + {{ "phone" | i18n }} + + + + + {{ "address" | i18n }} + + + + +
diff --git a/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.spec.ts b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.spec.ts new file mode 100644 index 0000000000..4c84b2852a --- /dev/null +++ b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.spec.ts @@ -0,0 +1,200 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; +import { SectionHeaderComponent } from "@bitwarden/components"; + +import { BitInputDirective } from "../../../../components/src/input/input.directive"; + +import { ViewIdentitySectionsComponent } from "./view-identity-sections.component"; + +describe("ViewIdentitySectionsComponent", () => { + let component: ViewIdentitySectionsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ViewIdentitySectionsComponent], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: PlatformUtilsService, useValue: mock() }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ViewIdentitySectionsComponent); + component = fixture.componentInstance; + component.cipher = { identity: new IdentityView() } as CipherView; + fixture.detectChanges(); + }); + + describe("personal details", () => { + it("dynamically shows the section", () => { + let personalDetailSection = fixture.debugElement.query(By.directive(SectionHeaderComponent)); + + expect(personalDetailSection).toBeNull(); + + component.cipher = { + identity: { + fullName: "Mr Ron Burgundy", + }, + } as CipherView; + + component.ngOnInit(); + fixture.detectChanges(); + + personalDetailSection = fixture.debugElement.query(By.directive(SectionHeaderComponent)); + + expect(personalDetailSection).not.toBeNull(); + expect(personalDetailSection.nativeElement.textContent).toBe("personalDetails"); + }); + + it("populates personal detail fields", () => { + component.cipher = { + identity: { + fullName: "Mr Ron Burgundy", + company: "Channel 4 News", + username: "ron.burgundy", + }, + } as CipherView; + + component.ngOnInit(); + fixture.detectChanges(); + + const fields = fixture.debugElement.queryAll(By.directive(BitInputDirective)); + + expect(fields[0].nativeElement.value).toBe("Mr Ron Burgundy"); + expect(fields[1].nativeElement.value).toBe("ron.burgundy"); + expect(fields[2].nativeElement.value).toBe("Channel 4 News"); + }); + }); + + describe("identification details", () => { + it("dynamically shows the section", () => { + let identificationDetailSection = fixture.debugElement.query( + By.directive(SectionHeaderComponent), + ); + + expect(identificationDetailSection).toBeNull(); + + component.cipher = { + identity: { + ssn: "123-45-6789", + }, + } as CipherView; + + component.ngOnInit(); + fixture.detectChanges(); + + identificationDetailSection = fixture.debugElement.query( + By.directive(SectionHeaderComponent), + ); + + expect(identificationDetailSection).not.toBeNull(); + expect(identificationDetailSection.nativeElement.textContent).toBe("identification"); + }); + + it("populates identification detail fields", () => { + component.cipher = { + identity: { + ssn: "123-45-6789", + passportNumber: "998-765-4321", + licenseNumber: "404-HTTP", + }, + } as CipherView; + + component.ngOnInit(); + fixture.detectChanges(); + + const fields = fixture.debugElement.queryAll(By.directive(BitInputDirective)); + + expect(fields[0].nativeElement.value).toBe("123-45-6789"); + expect(fields[1].nativeElement.value).toBe("998-765-4321"); + expect(fields[2].nativeElement.value).toBe("404-HTTP"); + }); + }); + + describe("contact details", () => { + it("dynamically shows the section", () => { + let contactDetailSection = fixture.debugElement.query(By.directive(SectionHeaderComponent)); + + expect(contactDetailSection).toBeNull(); + + component.cipher = { + identity: { + email: "jack@gnn.com", + }, + } as CipherView; + + component.ngOnInit(); + fixture.detectChanges(); + + contactDetailSection = fixture.debugElement.query(By.directive(SectionHeaderComponent)); + + expect(contactDetailSection).not.toBeNull(); + expect(contactDetailSection.nativeElement.textContent).toBe("contactInfo"); + }); + + it("populates contact detail fields", () => { + component.cipher = { + identity: { + email: "jack@gnn.com", + phone: "608-867-5309", + address1: "2920 Zoo Dr", + address2: "Exhibit 200", + address3: "Tree 7", + fullAddressPart2: "San Diego, CA 92101", + country: "USA", + }, + } as CipherView; + + component.ngOnInit(); + fixture.detectChanges(); + + const fields = fixture.debugElement.queryAll(By.directive(BitInputDirective)); + + expect(fields[0].nativeElement.value).toBe("jack@gnn.com"); + expect(fields[1].nativeElement.value).toBe("608-867-5309"); + expect(fields[2].nativeElement.value).toBe( + "2920 Zoo Dr\nExhibit 200\nTree 7\nSan Diego, CA 92101\nUSA", + ); + }); + + it('returns the number of "rows" that should be assigned to the address textarea', () => { + component.cipher = { + identity: { + address1: "2920 Zoo Dr", + address2: "Exhibit 200", + address3: "Tree 7", + fullAddressPart2: "San Diego, CA 92101", + country: "USA", + }, + } as CipherView; + + component.ngOnInit(); + fixture.detectChanges(); + + let textarea = fixture.debugElement.query(By.css("textarea")); + + expect(textarea.nativeElement.rows).toBe(5); + + component.cipher = { + identity: { + address1: "2920 Zoo Dr", + country: "USA", + }, + } as CipherView; + + fixture.detectChanges(); + + textarea = fixture.debugElement.query(By.css("textarea")); + + expect(textarea.nativeElement.rows).toBe(2); + }); + }); +}); diff --git a/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.ts b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.ts new file mode 100644 index 0000000000..0fd2c29295 --- /dev/null +++ b/libs/vault/src/cipher-view/view-identity-sections/view-identity-sections.component.ts @@ -0,0 +1,72 @@ +import { NgIf } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CardComponent, + FormFieldModule, + IconButtonModule, + SectionComponent, + SectionHeaderComponent, + TypographyModule, +} from "@bitwarden/components"; + +@Component({ + standalone: true, + selector: "app-view-identity-sections", + templateUrl: "./view-identity-sections.component.html", + imports: [ + NgIf, + JslibModule, + CardComponent, + SectionComponent, + SectionHeaderComponent, + TypographyModule, + FormFieldModule, + IconButtonModule, + ], +}) +export class ViewIdentitySectionsComponent implements OnInit { + @Input() cipher: CipherView; + + showPersonalDetails: boolean; + showIdentificationDetails: boolean; + showContactDetails: boolean; + + ngOnInit(): void { + this.showPersonalDetails = this.hasPersonalDetails(); + this.showIdentificationDetails = this.hasIdentificationDetails(); + this.showContactDetails = this.hasContactDetails(); + } + + /** Returns all populated address fields */ + get addressFields(): string { + const { address1, address2, address3, fullAddressPart2, country } = this.cipher.identity; + return [address1, address2, address3, fullAddressPart2, country].filter(Boolean).join("\n"); + } + + /** Returns the number of "rows" that should be assigned to the address textarea */ + get addressRows(): number { + return this.addressFields.split("\n").length; + } + + /** Returns true when any of the "personal detail" attributes are populated */ + private hasPersonalDetails(): boolean { + const { username, company, fullName } = this.cipher.identity; + return Boolean(fullName || username || company); + } + + /** Returns true when any of the "identification detail" attributes are populated */ + private hasIdentificationDetails(): boolean { + const { ssn, passportNumber, licenseNumber } = this.cipher.identity; + return Boolean(ssn || passportNumber || licenseNumber); + } + + /** Returns true when any of the "contact detail" attributes are populated */ + private hasContactDetails(): boolean { + const { email, phone } = this.cipher.identity; + + return Boolean(email || phone || this.addressFields); + } +}