[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`
This commit is contained in:
Nick Krantz 2024-07-30 10:55:09 -05:00 committed by GitHub
parent 81212deaad
commit 84ee01caee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 454 additions and 0 deletions

View File

@ -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"
},

View File

@ -8,6 +8,10 @@
>
</app-item-details-v2>
<!-- IDENTITY SECTIONS -->
<app-view-identity-sections *ngIf="cipher.identity" [cipher]="cipher">
</app-view-identity-sections>
<!-- ADDITIONAL OPTIONS -->
<ng-container *ngIf="cipher.notes">
<app-additional-options [notes]="cipher.notes"> </app-additional-options>

View File

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

View File

@ -0,0 +1,161 @@
<bit-section *ngIf="showPersonalDetails">
<bit-section-header>
<h2 bitTypography="h6">{{ "personalDetails" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-form-field *ngIf="cipher.identity.fullName">
<bit-label>{{ "name" | i18n }}</bit-label>
<input bitInput [value]="cipher.identity.fullName" readonly data-testid="name" />
<button
type="button"
bitIconButton="bwi-clone"
bitSuffix
[appA11yTitle]="'copyName' | i18n"
[appCopyClick]="cipher.identity.fullName"
showToast
></button>
</bit-form-field>
<bit-form-field *ngIf="cipher.identity.username">
<bit-label>{{ "username" | i18n }}</bit-label>
<input bitInput [value]="cipher.identity.username" readonly data-testid="username" />
<button
type="button"
bitIconButton="bwi-clone"
bitSuffix
[appA11yTitle]="'copyUsername' | i18n"
[appCopyClick]="cipher.identity.username"
showToast
></button>
</bit-form-field>
<bit-form-field *ngIf="cipher.identity.company">
<bit-label>{{ "company" | i18n }}</bit-label>
<input bitInput [value]="cipher.identity.company" readonly data-testid="company" />
<button
type="button"
bitIconButton="bwi-clone"
bitSuffix
[appA11yTitle]="'copyCompany' | i18n"
[appCopyClick]="cipher.identity.company"
showToast
></button>
</bit-form-field>
</bit-card>
</bit-section>
<bit-section *ngIf="showIdentificationDetails">
<bit-section-header>
<h2 bitTypography="h6">{{ "identification" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-form-field *ngIf="cipher.identity.ssn">
<bit-label>{{ "ssn" | i18n }}</bit-label>
<input bitInput type="password" [value]="cipher.identity.ssn" readonly data-testid="ssn" />
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
data-testid="ssn-toggle"
></button>
<button
type="button"
bitIconButton="bwi-clone"
bitSuffix
[appA11yTitle]="'copySSN' | i18n"
[appCopyClick]="cipher.identity.ssn"
showToast
></button>
</bit-form-field>
<bit-form-field *ngIf="cipher.identity.passportNumber">
<bit-label>{{ "passportNumber" | i18n }}</bit-label>
<input
bitInput
type="password"
[value]="cipher.identity.passportNumber"
readonly
data-testid="passport"
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
data-testid="passport-toggle"
></button>
<button
type="button"
bitIconButton="bwi-clone"
bitSuffix
[appA11yTitle]="'copyPassportNumber' | i18n"
[appCopyClick]="cipher.identity.passportNumber"
showToast
></button>
</bit-form-field>
<bit-form-field *ngIf="cipher.identity.licenseNumber">
<bit-label>{{ "licenseNumber" | i18n }}</bit-label>
<input bitInput [value]="cipher.identity.licenseNumber" readonly data-testid="license" />
<button
type="button"
bitIconButton="bwi-clone"
bitSuffix
[appA11yTitle]="'copyLicenseNumber' | i18n"
[appCopyClick]="cipher.identity.licenseNumber"
showToast
></button>
</bit-form-field>
</bit-card>
</bit-section>
<bit-section *ngIf="showContactDetails">
<bit-section-header>
<h2 bitTypography="h6">{{ "contactInfo" | i18n }}</h2>
</bit-section-header>
<bit-card>
<bit-form-field *ngIf="cipher.identity.email">
<bit-label>{{ "email" | i18n }}</bit-label>
<input bitInput [value]="cipher.identity.email" readonly data-testid="email" />
<button
type="button"
bitIconButton="bwi-clone"
bitSuffix
[appA11yTitle]="'copyEmail' | i18n"
[appCopyClick]="cipher.identity.email"
showToast
></button>
</bit-form-field>
<bit-form-field *ngIf="cipher.identity.phone">
<bit-label>{{ "phone" | i18n }}</bit-label>
<input bitInput [value]="cipher.identity.phone" readonly data-testid="phone" />
<button
type="button"
bitIconButton="bwi-clone"
bitSuffix
[appA11yTitle]="'copyPhone' | i18n"
[appCopyClick]="cipher.identity.phone"
showToast
></button>
</bit-form-field>
<bit-form-field *ngIf="addressFields">
<bit-label>{{ "address" | i18n }}</bit-label>
<textarea
bitInput
class="tw-resize-none"
[value]="addressFields"
[rows]="addressRows"
readonly
data-testid="address"
></textarea>
<button
type="button"
bitIconButton="bwi-clone"
bitSuffix
[appA11yTitle]="'copyAddress' | i18n"
[appCopyClick]="addressFields"
showToast
></button>
</bit-form-field>
</bit-card>
</bit-section>

View File

@ -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<ViewIdentitySectionsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ViewIdentitySectionsComponent],
providers: [
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
],
}).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);
});
});
});

View File

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