diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ad933c24875..f8dde376b35 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5573,5 +5573,11 @@ "wasmNotSupported": { "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." + }, + "showMore": { + "message": "Show more" + }, + "showLess": { + "message": "Show less" } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 8b30bd85ec9..72d40ed750f 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3620,7 +3620,7 @@ }, "uriMatchDefaultStrategyHint": { "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": { "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": { "message": "Enable Autotype" }, diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts index b341fc4f8e4..f0ecca1686d 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts @@ -2,11 +2,14 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -60,6 +63,11 @@ describe("EmergencyViewDialogComponent", () => { { provide: AccountService, useValue: accountService }, { provide: TaskService, useValue: mock() }, { provide: LogService, useValue: mock() }, + { + provide: EnvironmentService, + useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) }, + }, + { provide: DomainSettingsService, useValue: { showFavicons$: of(true) } }, ], }) .overrideComponent(EmergencyViewDialogComponent, { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 587dcd84e0c..4eaf141abc2 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11002,5 +11002,11 @@ }, "providersubCanceledmessage": { "message" : "To resubscribe, contact Bitwarden Customer Support." + }, + "showMore": { + "message": "Show more" + }, + "showLess": { + "message": "Show less" } -} \ No newline at end of file +} diff --git a/libs/angular/src/vault/components/icon.component.html b/libs/angular/src/vault/components/icon.component.html index 2dae3b26cc5..0f14de64e21 100644 --- a/libs/angular/src/vault/components/icon.component.html +++ b/libs/angular/src/vault/components/icon.component.html @@ -1,19 +1,44 @@ - + +
+ @for (item of showItems(); track item.id; let last = $last) { + - + @if (isOrgIcon(item)) { + + } @else { + + } - - - -
  • - - -
  • - +
    + } + @if (allItems().length === 0) { + + + + + } + @if (hasSmallScreen() && allItems().length > 2 && cipher().collectionIds.length > 1) { + + + } +
    +
    diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts b/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts index f093cd020b5..ead2979fac7 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts @@ -1,11 +1,17 @@ +import { ComponentRef } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; 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. // eslint-disable-next-line no-restricted-imports import { CollectionView } from "@bitwarden/admin-console/common"; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; @@ -14,6 +20,7 @@ import { ItemDetailsV2Component } from "./item-details-v2.component"; describe("ItemDetailsV2Component", () => { let component: ItemDetailsV2Component; let fixture: ComponentFixture; + let componentRef: ComponentRef; const cipher = { id: "cipher1", @@ -46,37 +53,46 @@ describe("ItemDetailsV2Component", () => { beforeEach(async () => { await TestBed.configureTestingModule({ 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(); }); beforeEach(() => { fixture = TestBed.createComponent(ItemDetailsV2Component); component = fixture.componentInstance; - component.cipher = cipher; - component.organization = organization; - component.collections = [collection, collection2]; - component.folder = folder; + componentRef = fixture.componentRef; + componentRef.setInput("cipher", cipher); + componentRef.setInput("organization", organization); + componentRef.setInput("collections", [collection, collection2]); + componentRef.setInput("folder", folder); + jest.spyOn(component, "hasSmallScreen").mockReturnValue(false); // Mocking small screen check fixture.detectChanges(); }); it("displays all available fields", () => { const itemName = fixture.debugElement.query(By.css('[data-testid="item-name"]')); - const owner = fixture.debugElement.query(By.css('[data-testid="owner"]')); - const collections = fixture.debugElement.queryAll(By.css('[data-testid="collections"] li')); - const folderElement = fixture.debugElement.query(By.css('[data-testid="folder"]')); + const itemDetailsList = fixture.debugElement.queryAll( + By.css('[data-testid="item-details-list"]'), + ); - expect(itemName.nativeElement.value).toBe(cipher.name); - expect(owner.nativeElement.textContent.trim()).toBe(organization.name); - expect(collections.map((c) => c.nativeElement.textContent.trim())).toEqual([ - collection.name, - collection2.name, - ]); - expect(folderElement.nativeElement.textContent.trim()).toBe(folder.name); + expect(itemName.nativeElement.textContent.trim()).toEqual(cipher.name); + expect(itemDetailsList.length).toBe(4); // Organization, Collection, Collection2, Folder + expect(itemDetailsList[0].nativeElement.textContent.trim()).toContain(organization.name); + expect(itemDetailsList[1].nativeElement.textContent.trim()).toContain(collection.name); + expect(itemDetailsList[2].nativeElement.textContent.trim()).toContain(collection2.name); + expect(itemDetailsList[3].nativeElement.textContent.trim()).toContain(folder.name); }); it("does not render owner when `hideOwner` is true", () => { - component.hideOwner = true; + componentRef.setInput("hideOwner", true); fixture.detectChanges(); const owner = fixture.debugElement.query(By.css('[data-testid="owner"]')); diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts index 8f0fedbe599..6ccd0b7ee61 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts @@ -1,19 +1,22 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore 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. +import { toSignal } from "@angular/core/rxjs-interop"; +import { fromEvent, map, startWith } from "rxjs"; + // eslint-disable-next-line no-restricted-imports import { CollectionView } from "@bitwarden/admin-console/common"; 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 { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { + ButtonLinkDirective, CardComponent, FormFieldModule, - SectionHeaderComponent, TypographyModule, } from "@bitwarden/components"; @@ -26,20 +29,96 @@ import { OrgIconDirective } from "../../components/org-icon.directive"; CommonModule, JslibModule, CardComponent, - SectionHeaderComponent, TypographyModule, OrgIconDirective, FormFieldModule, + ButtonLinkDirective, ], }) export class ItemDetailsV2Component { - @Input() cipher: CipherView; - @Input() organization?: Organization; - @Input() collections?: CollectionView[]; - @Input() folder?: FolderView; - @Input() hideOwner?: boolean = false; + hideOwner = input(false); + cipher = input.required(); + organization = input(); + folder = input(); + collections = input(); + showAllDetails = signal(false); - get showOwnership() { - return this.cipher.organizationId && this.organization && !this.hideOwner; + showOwnership = computed(() => { + 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; } }