diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 6077afa8c12..f75f6ccdc20 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -361,6 +361,7 @@ const routes: Routes = [ { path: "new-sends", component: SendV2Component, + data: { pageTitle: { key: "send" } } satisfies RouteDataProperties, }, ], }, diff --git a/apps/desktop/src/app/layout/header/desktop-header.component.html b/apps/desktop/src/app/layout/header/desktop-header.component.html new file mode 100644 index 00000000000..efee5e21d9b --- /dev/null +++ b/apps/desktop/src/app/layout/header/desktop-header.component.html @@ -0,0 +1,21 @@ +
+ + + + + + + + + + + + + + + + + + + +
diff --git a/apps/desktop/src/app/layout/header/desktop-header.component.spec.ts b/apps/desktop/src/app/layout/header/desktop-header.component.spec.ts new file mode 100644 index 00000000000..8d3db198887 --- /dev/null +++ b/apps/desktop/src/app/layout/header/desktop-header.component.spec.ts @@ -0,0 +1,122 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { HeaderComponent } from "@bitwarden/components"; + +import { DesktopHeaderComponent } from "./desktop-header.component"; + +describe("DesktopHeaderComponent", () => { + let component: DesktopHeaderComponent; + let fixture: ComponentFixture; + let mockI18nService: ReturnType>; + let mockActivatedRoute: { data: any }; + + beforeEach(async () => { + mockI18nService = mock(); + mockI18nService.t.mockImplementation((key: string) => `translated_${key}`); + + mockActivatedRoute = { + data: of({}), + }; + + await TestBed.configureTestingModule({ + imports: [DesktopHeaderComponent, HeaderComponent], + providers: [ + { + provide: I18nService, + useValue: mockI18nService, + }, + { + provide: ActivatedRoute, + useValue: mockActivatedRoute, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("creates component", () => { + expect(component).toBeTruthy(); + }); + + it("renders bit-header component", () => { + const compiled = fixture.nativeElement; + const headerElement = compiled.querySelector("bit-header"); + + expect(headerElement).toBeTruthy(); + }); + + describe("title resolution", () => { + it("uses title input when provided", () => { + fixture.componentRef.setInput("title", "Direct Title"); + fixture.detectChanges(); + + expect(component["resolvedTitle"]()).toBe("Direct Title"); + }); + + it("uses route data titleId when no direct title provided", () => { + mockActivatedRoute.data = of({ + pageTitle: { key: "sends" }, + }); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(mockI18nService.t).toHaveBeenCalledWith("sends"); + expect(component["resolvedTitle"]()).toBe("translated_sends"); + }); + + it("returns empty string when no title or route data provided", () => { + mockActivatedRoute.data = of({}); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component["resolvedTitle"]()).toBe(""); + }); + + it("prioritizes direct title over route data", () => { + mockActivatedRoute.data = of({ + pageTitle: { key: "sends" }, + }); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("title", "Override Title"); + fixture.detectChanges(); + + expect(component["resolvedTitle"]()).toBe("Override Title"); + }); + }); + + describe("icon input", () => { + it("accepts icon input", () => { + fixture.componentRef.setInput("icon", "bwi-send"); + fixture.detectChanges(); + + expect(component.icon()).toBe("bwi-send"); + }); + + it("defaults to undefined when no icon provided", () => { + expect(component.icon()).toBeUndefined(); + }); + }); + + describe("content projection", () => { + it("wraps bit-header component for slot pass-through", () => { + const compiled = fixture.nativeElement; + const bitHeader = compiled.querySelector("bit-header"); + + // Verify bit-header exists and can receive projected content + expect(bitHeader).toBeTruthy(); + }); + }); +}); diff --git a/apps/desktop/src/app/layout/header/desktop-header.component.ts b/apps/desktop/src/app/layout/header/desktop-header.component.ts new file mode 100644 index 00000000000..5a837f1ff5a --- /dev/null +++ b/apps/desktop/src/app/layout/header/desktop-header.component.ts @@ -0,0 +1,47 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { ActivatedRoute } from "@angular/router"; +import { map } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { HeaderComponent, BannerModule } from "@bitwarden/components"; + +@Component({ + selector: "app-header", + templateUrl: "./desktop-header.component.html", + imports: [BannerModule, HeaderComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DesktopHeaderComponent { + private route = inject(ActivatedRoute); + private i18nService = inject(I18nService); + + /** + * Title to display in header (takes precedence over route data) + */ + readonly title = input(); + + /** + * Icon to show before the title + */ + readonly icon = input(); + + private readonly routeData = toSignal( + this.route.data.pipe( + map((params) => ({ + titleId: params["pageTitle"]?.["key"] as string | undefined, + })), + ), + { initialValue: { titleId: undefined } }, + ); + + protected readonly resolvedTitle = computed(() => { + const directTitle = this.title(); + if (directTitle) { + return directTitle; + } + + const titleId = this.routeData().titleId; + return titleId ? this.i18nService.t(titleId) : ""; + }); +} diff --git a/apps/desktop/src/app/layout/header/index.ts b/apps/desktop/src/app/layout/header/index.ts new file mode 100644 index 00000000000..793d90f81e5 --- /dev/null +++ b/apps/desktop/src/app/layout/header/index.ts @@ -0,0 +1 @@ +export { DesktopHeaderComponent } from "./desktop-header.component"; diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.html b/apps/desktop/src/app/tools/send-v2/send-v2.component.html index 659e4be9c5b..05c1332f1e7 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.html +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.html @@ -1,40 +1,31 @@
-
- -
-

{{ "send" | i18n }}

- @if (!disableSend()) { - - } -
- + + + @if (!disableSend()) { + + } + +
-
- - - -
+ + +
diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts index 58c9ce8e0b4..713915e3cf7 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts @@ -3,6 +3,7 @@ import { ChangeDetectorRef } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { provideNoopAnimations } from "@angular/platform-browser/animations"; +import { ActivatedRoute } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; @@ -89,6 +90,12 @@ describe("SendV2Component", () => { }, { provide: MessagingService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, + { + provide: ActivatedRoute, + useValue: { + data: of({}), + }, + }, ], }).compileComponents(); diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts index 1b2ccb30247..6a44713d309 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -35,6 +35,7 @@ import { } from "@bitwarden/send-ui"; import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; +import { DesktopHeaderComponent } from "../../layout/header"; import { AddEditComponent } from "../send/add-edit.component"; const Action = Object.freeze({ @@ -56,6 +57,7 @@ type Action = (typeof Action)[keyof typeof Action]; AddEditComponent, SendListComponent, NewSendDropdownV2Component, + DesktopHeaderComponent, ], providers: [ { diff --git a/libs/components/src/header/header.component.ts b/libs/components/src/header/header.component.ts index 08cd91ea206..44b0c063d89 100644 --- a/libs/components/src/header/header.component.ts +++ b/libs/components/src/header/header.component.ts @@ -1,8 +1,11 @@ import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { TypographyDirective } from "../typography/typography.directive"; + @Component({ selector: "bit-header", templateUrl: "./header.component.html", + imports: [TypographyDirective], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, })