1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00

[PM-18066] new item details view (#15311)

* update item details v2 in libs for new view design. targets web, browser, and desktop
This commit is contained in:
Jason Ng
2025-08-07 13:21:24 -04:00
committed by GitHub
parent c3f6892f9e
commit 2ef8b1a6bf
9 changed files with 273 additions and 119 deletions

View File

@@ -5573,5 +5573,11 @@
"wasmNotSupported": { "wasmNotSupported": {
"message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.", "message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.",
"description": "'WebAssembly' is a technical term and should not be translated." "description": "'WebAssembly' is a technical term and should not be translated."
},
"showMore": {
"message": "Show more"
},
"showLess": {
"message": "Show less"
} }
} }

View File

@@ -3620,7 +3620,7 @@
}, },
"uriMatchDefaultStrategyHint": { "uriMatchDefaultStrategyHint": {
"message": "URI match detection is how Bitwarden identifies autofill suggestions.", "message": "URI match detection is how Bitwarden identifies autofill suggestions.",
"description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item."
}, },
"regExAdvancedOptionWarning": { "regExAdvancedOptionWarning": {
"message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.",
@@ -4066,6 +4066,12 @@
} }
} }
}, },
"showMore": {
"message": "Show more"
},
"showLess": {
"message": "Show less"
},
"enableAutotype": { "enableAutotype": {
"message": "Enable Autotype" "message": "Enable Autotype"
}, },

View File

@@ -2,11 +2,14 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser"; import { By } from "@angular/platform-browser";
import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common"; import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -60,6 +63,11 @@ describe("EmergencyViewDialogComponent", () => {
{ provide: AccountService, useValue: accountService }, { provide: AccountService, useValue: accountService },
{ provide: TaskService, useValue: mock<TaskService>() }, { provide: TaskService, useValue: mock<TaskService>() },
{ provide: LogService, useValue: mock<LogService>() }, { provide: LogService, useValue: mock<LogService>() },
{
provide: EnvironmentService,
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
},
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
], ],
}) })
.overrideComponent(EmergencyViewDialogComponent, { .overrideComponent(EmergencyViewDialogComponent, {

View File

@@ -11002,5 +11002,11 @@
}, },
"providersubCanceledmessage": { "providersubCanceledmessage": {
"message" : "To resubscribe, contact Bitwarden Customer Support." "message" : "To resubscribe, contact Bitwarden Customer Support."
},
"showMore": {
"message": "Show more"
},
"showLess": {
"message": "Show less"
} }
} }

View File

@@ -1,19 +1,44 @@
<div class="tw-flex tw-justify-center tw-items-center" aria-hidden="true"> <!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
<div
class="tw-flex tw-justify-center tw-items-center"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
aria-hidden="true"
>
<ng-container *ngIf="data$ | async as data"> <ng-container *ngIf="data$ | async as data">
<img @if (data.imageEnabled && data.image) {
[src]="data.image" <img
*ngIf="data.imageEnabled && data.image" [src]="data.image"
class="tw-size-6 tw-rounded-md" class="tw-rounded-md"
alt="" alt=""
decoding="async" decoding="async"
loading="lazy" loading="lazy"
[ngClass]="{ 'tw-invisible tw-absolute': !imageLoaded() }" [ngClass]="{
(load)="imageLoaded.set(true)" 'tw-invisible tw-absolute': !imageLoaded(),
(error)="imageLoaded.set(false)" 'tw-size-6': !coloredIcon(),
/> }"
<i [ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
class="tw-w-6 tw-text-muted bwi bwi-lg {{ data.icon }}" (load)="imageLoaded.set(true)"
*ngIf="!data.imageEnabled || !data.image || !imageLoaded()" (error)="imageLoaded.set(false)"
></i> />
}
@if (!data.imageEnabled || !data.image || !imageLoaded()) {
<div
[ngClass]="{
'tw-flex tw-items-center tw-justify-center': coloredIcon(),
'tw-bg-illustration-bg-primary tw-rounded-full':
data.icon?.startsWith('bwi-') && coloredIcon(),
}"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
>
<i
class="tw-text-muted bwi bwi-lg {{ data.icon }}"
[ngStyle]="{
color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null,
width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null,
height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null,
}"
></i>
</div>
}
</ng-container> </ng-container>
</div> </div>

View File

@@ -27,6 +27,11 @@ export class IconComponent {
*/ */
cipher = input.required<CipherViewLike>(); cipher = input.required<CipherViewLike>();
/**
* coloredIcon will adjust the size of favicons and the colors of the text icon when user is in the item details view.
*/
coloredIcon = input<boolean>(false);
imageLoaded = signal(false); imageLoaded = signal(false);
protected data$: Observable<CipherIconDetails>; protected data$: Observable<CipherIconDetails>;

View File

@@ -1,83 +1,86 @@
<section class="tw-mb-5 bit-compact:tw-mb-4"> <section class="tw-mb-5 bit-compact:tw-mb-4">
<bit-section-header>
<h2 bitTypography="h6">{{ "itemDetails" | i18n }}</h2>
</bit-section-header>
<bit-card> <bit-card>
<bit-form-field <div
[disableMargin]="!cipher.collectionIds?.length && !showOwnership && !cipher.folderId" class="tw-flex tw-place-items-center"
[disableReadOnlyBorder]="!cipher.collectionIds?.length && !showOwnership && !cipher.folderId" [ngClass]="{
'tw-mb-2': allItems.length > 0,
}"
> >
<bit-label [appTextDrag]="cipher.name"> <!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
{{ "itemName" | i18n }} <div class="tw-flex tw-items-center tw-justify-center" style="width: 40px; height: 40px">
</bit-label> <app-vault-icon [cipher]="cipher()" [coloredIcon]="true"></app-vault-icon>
<input </div>
readonly <h2 bitTypography="h4" class="tw-ml-2 tw-mt-2" data-testid="item-name">
id="itemName" {{ cipher().name }}
bitInput </h2>
type="text" </div>
[value]="cipher.name" <ng-container>
aria-readonly="true" <div class="tw-flex tw-flex-col tw-mt-2 md:tw-flex-row md:tw-flex-wrap">
data-testid="item-name" @for (item of showItems(); track item.id; let last = $last) {
/> <span
</bit-form-field> class="tw-flex tw-items-center tw-mt-2 tw-mr-4"
<ul
[attr.aria-label]="'itemLocation' | i18n"
*ngIf="cipher.collectionIds?.length || showOwnership || cipher.folderId"
class="tw-mb-0 tw-pl-0"
>
<li
*ngIf="showOwnership && organization"
class="tw-flex tw-items-center tw-list-none"
[ngClass]="{ 'tw-mb-3': cipher.collectionIds }"
bitTypography="body2"
[attr.aria-label]="('owner' | i18n) + organization.name"
data-testid="owner"
>
<i
appOrgIcon
[tierType]="organization.productTierType"
[size]="'large'"
[title]="'owner' | i18n"
></i>
<span aria-hidden="true" class="tw-pl-1.5">
{{ organization.name }}
</span>
</li>
<li
class="tw-list-none"
*ngIf="cipher.collectionIds && collections"
[attr.aria-label]="'collection' | i18n"
>
<ul data-testid="collections" [ngClass]="{ 'tw-mb-0': !cipher.folderId }" class="tw-pl-0">
<li
*ngFor="let collection of collections; let last = last"
class="tw-flex tw-items-center tw-list-none"
bitTypography="body2" bitTypography="body2"
[ngClass]="{ 'tw-mb-3': last && cipher.folderId }" [attr.aria-label]="getAriaLabel(item)"
[attr.aria-label]="collection.name" [ngClass]="{ 'tw-mb-2': last && hasSmallScreen() }"
data-testid="item-details-list"
> >
<i @if (isOrgIcon(item)) {
class="bwi bwi-collection-shared bwi-lg" <i
aria-hidden="true" appOrgIcon
[title]="'collection' | i18n" [tierType]="organization().productTierType"
></i> [size]="'large'"
[title]="'owner' | i18n"
></i>
} @else {
<i
class="bwi bwi-lg"
[ngClass]="getIconClass(item)"
aria-hidden="true"
[title]="getItemTitle(item)"
></i>
}
<span aria-hidden="true" class="tw-pl-1.5"> <span aria-hidden="true" class="tw-pl-1.5">
{{ collection.name }} {{ item.name }}
</span> </span>
</li> </span>
</ul> }
</li> @if (allItems().length === 0) {
<li <span
*ngIf="cipher.folderId && folder" class="tw-flex tw-items-center tw-mt-2 tw-mr-4"
bitTypography="body2" bitTypography="body2"
class="tw-flex tw-items-center tw-list-none" [attr.aria-label]="'noneFolder' | i18n"
[attr.aria-label]="('folder' | i18n) + folder.name" >
data-testid="folder" <i class="bwi bwi-folder bwi-lg" aria-hidden="true" [title]="'folder' | i18n"></i>
> <span aria-hidden="true" class="tw-pl-1.5">
<i class="bwi bwi-folder bwi-lg" aria-hidden="true" [title]="'folder' | i18n"></i> {{ "noneFolder" | i18n }}
<span aria-hidden="true" class="tw-pl-1.5">{{ folder.name }} </span> </span>
</li> </span>
</ul> }
@if (hasSmallScreen() && allItems().length > 2 && cipher().collectionIds.length > 1) {
<button
bitTypography="body2"
type="button"
bitLink
linkType="primary"
class="tw-mt-1.5"
(click)="toggleShowMore()"
*ngIf="!showAllDetails()"
>
{{ "showMore" | i18n }}
</button>
<button
bitTypography="body2"
type="button"
bitLink
linkType="primary"
class="tw-mt-1.5"
(click)="toggleShowMore()"
*ngIf="showAllDetails()"
>
{{ "showLess" | i18n }}
</button>
}
</div>
</ng-container>
</bit-card> </bit-card>
</section> </section>

View File

@@ -1,11 +1,17 @@
import { ComponentRef } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser"; import { By } from "@angular/platform-browser";
import { of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common"; import { CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { ClientType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; 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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -14,6 +20,7 @@ import { ItemDetailsV2Component } from "./item-details-v2.component";
describe("ItemDetailsV2Component", () => { describe("ItemDetailsV2Component", () => {
let component: ItemDetailsV2Component; let component: ItemDetailsV2Component;
let fixture: ComponentFixture<ItemDetailsV2Component>; let fixture: ComponentFixture<ItemDetailsV2Component>;
let componentRef: ComponentRef<ItemDetailsV2Component>;
const cipher = { const cipher = {
id: "cipher1", id: "cipher1",
@@ -46,37 +53,46 @@ describe("ItemDetailsV2Component", () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ItemDetailsV2Component], imports: [ItemDetailsV2Component],
providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], providers: [
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: PlatformUtilsService, useValue: { getClientType: () => ClientType.Web } },
{
provide: EnvironmentService,
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
},
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
],
}).compileComponents(); }).compileComponents();
}); });
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(ItemDetailsV2Component); fixture = TestBed.createComponent(ItemDetailsV2Component);
component = fixture.componentInstance; component = fixture.componentInstance;
component.cipher = cipher; componentRef = fixture.componentRef;
component.organization = organization; componentRef.setInput("cipher", cipher);
component.collections = [collection, collection2]; componentRef.setInput("organization", organization);
component.folder = folder; componentRef.setInput("collections", [collection, collection2]);
componentRef.setInput("folder", folder);
jest.spyOn(component, "hasSmallScreen").mockReturnValue(false); // Mocking small screen check
fixture.detectChanges(); fixture.detectChanges();
}); });
it("displays all available fields", () => { it("displays all available fields", () => {
const itemName = fixture.debugElement.query(By.css('[data-testid="item-name"]')); const itemName = fixture.debugElement.query(By.css('[data-testid="item-name"]'));
const owner = fixture.debugElement.query(By.css('[data-testid="owner"]')); const itemDetailsList = fixture.debugElement.queryAll(
const collections = fixture.debugElement.queryAll(By.css('[data-testid="collections"] li')); By.css('[data-testid="item-details-list"]'),
const folderElement = fixture.debugElement.query(By.css('[data-testid="folder"]')); );
expect(itemName.nativeElement.value).toBe(cipher.name); expect(itemName.nativeElement.textContent.trim()).toEqual(cipher.name);
expect(owner.nativeElement.textContent.trim()).toBe(organization.name); expect(itemDetailsList.length).toBe(4); // Organization, Collection, Collection2, Folder
expect(collections.map((c) => c.nativeElement.textContent.trim())).toEqual([ expect(itemDetailsList[0].nativeElement.textContent.trim()).toContain(organization.name);
collection.name, expect(itemDetailsList[1].nativeElement.textContent.trim()).toContain(collection.name);
collection2.name, expect(itemDetailsList[2].nativeElement.textContent.trim()).toContain(collection2.name);
]); expect(itemDetailsList[3].nativeElement.textContent.trim()).toContain(folder.name);
expect(folderElement.nativeElement.textContent.trim()).toBe(folder.name);
}); });
it("does not render owner when `hideOwner` is true", () => { it("does not render owner when `hideOwner` is true", () => {
component.hideOwner = true; componentRef.setInput("hideOwner", true);
fixture.detectChanges(); fixture.detectChanges();
const owner = fixture.debugElement.query(By.css('[data-testid="owner"]')); const owner = fixture.debugElement.query(By.css('[data-testid="owner"]'));

View File

@@ -1,19 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core"; import { Component, computed, input, signal } from "@angular/core";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
import { toSignal } from "@angular/core/rxjs-interop";
import { fromEvent, map, startWith } from "rxjs";
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { CollectionView } from "@bitwarden/admin-console/common"; import { CollectionView } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; 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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { import {
ButtonLinkDirective,
CardComponent, CardComponent,
FormFieldModule, FormFieldModule,
SectionHeaderComponent,
TypographyModule, TypographyModule,
} from "@bitwarden/components"; } from "@bitwarden/components";
@@ -26,20 +29,96 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
CommonModule, CommonModule,
JslibModule, JslibModule,
CardComponent, CardComponent,
SectionHeaderComponent,
TypographyModule, TypographyModule,
OrgIconDirective, OrgIconDirective,
FormFieldModule, FormFieldModule,
ButtonLinkDirective,
], ],
}) })
export class ItemDetailsV2Component { export class ItemDetailsV2Component {
@Input() cipher: CipherView; hideOwner = input<boolean>(false);
@Input() organization?: Organization; cipher = input.required<CipherView>();
@Input() collections?: CollectionView[]; organization = input<Organization | undefined>();
@Input() folder?: FolderView; folder = input<FolderView | undefined>();
@Input() hideOwner?: boolean = false; collections = input<CollectionView[] | undefined>();
showAllDetails = signal(false);
get showOwnership() { showOwnership = computed(() => {
return this.cipher.organizationId && this.organization && !this.hideOwner; return this.cipher().organizationId && this.organization() && !this.hideOwner();
});
hasSmallScreen = toSignal(
fromEvent(window, "resize").pipe(
map(() => window.innerWidth),
startWith(window.innerWidth),
map((width) => width < 681),
),
);
// Array to hold all details of item. Organization, Collections, and Folder
allItems = computed(() => {
let items: any[] = [];
if (this.showOwnership() && this.organization()) {
items.push(this.organization());
}
if (this.cipher().collectionIds?.length > 0 && this.collections()) {
items = [...items, ...this.collections()];
}
if (this.cipher().folderId && this.folder()) {
items.push(this.folder());
}
return items;
});
showItems = computed(() => {
if (
this.hasSmallScreen() &&
this.allItems().length > 2 &&
!this.showAllDetails() &&
this.cipher().collectionIds?.length > 1
) {
return this.allItems().slice(0, 2);
} else {
return this.allItems();
}
});
constructor(private i18nService: I18nService) {}
toggleShowMore() {
this.showAllDetails.update((value) => !value);
}
getAriaLabel(item: Organization | CollectionView | FolderView): string {
if (item instanceof Organization) {
return this.i18nService.t("owner") + item.name;
} else if (item instanceof CollectionView) {
return this.i18nService.t("collection") + item.name;
} else if (item instanceof FolderView) {
return this.i18nService.t("folder") + item.name;
}
return "";
}
getIconClass(item: Organization | CollectionView | FolderView): string {
if (item instanceof CollectionView) {
return "bwi-collection-shared";
} else if (item instanceof FolderView) {
return "bwi-folder";
}
return "";
}
getItemTitle(item: Organization | CollectionView | FolderView): string {
if (item instanceof CollectionView) {
return this.i18nService.t("collection");
} else if (item instanceof FolderView) {
return this.i18nService.t("folder");
}
return "";
}
isOrgIcon(item: Organization | CollectionView | FolderView): boolean {
return item instanceof Organization;
} }
} }