Feature/pm 28788 desktop header UI migration (#18221)
Some checks are pending
Auto Update Branch / Update Branch (push) Waiting to run
Chromatic / Check PR run (push) Waiting to run
Chromatic / Chromatic (push) Blocked by required conditions
Lint / Lint (push) Waiting to run
Lint / Run Rust lint on macos-14 (push) Waiting to run
Lint / Run Rust lint on ubuntu-24.04 (push) Waiting to run
Lint / Run Rust lint on windows-2022 (push) Waiting to run
Testing / Upload to Codecov (push) Blocked by required conditions
Scan / Check PR run (push) Waiting to run
Scan / Checkmarx (push) Blocked by required conditions
Scan / Sonar (push) Blocked by required conditions
Testing / Run tests (push) Waiting to run
Testing / Run Rust tests on macos-14 (push) Waiting to run
Testing / Run Rust tests on ubuntu-22.04 (push) Waiting to run
Testing / Run Rust tests on windows-2022 (push) Waiting to run
Testing / Rust Coverage (push) Waiting to run

Add desktop header component
This commit is contained in:
Isaac Ivins 2026-01-09 03:41:15 -05:00 committed by GitHub
parent 1022d21654
commit 95100b6f23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 229 additions and 34 deletions

View file

@ -361,6 +361,7 @@ const routes: Routes = [
{
path: "new-sends",
component: SendV2Component,
data: { pageTitle: { key: "send" } } satisfies RouteDataProperties,
},
],
},

View file

@ -0,0 +1,21 @@
<div class="tw-my-4 tw-px-4">
<bit-header [title]="resolvedTitle()" [icon]="icon()">
<ng-container slot="breadcrumbs">
<ng-content select="[slot=breadcrumbs]" />
</ng-container>
<ng-content />
<ng-container slot="title-suffix">
<ng-content select="[slot=title-suffix]" />
</ng-container>
<ng-container slot="secondary">
<ng-content select="[slot=secondary]" />
</ng-container>
<ng-container slot="tabs">
<ng-content select="[slot=tabs]" />
</ng-container>
</bit-header>
</div>

View file

@ -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<DesktopHeaderComponent>;
let mockI18nService: ReturnType<typeof mock<I18nService>>;
let mockActivatedRoute: { data: any };
beforeEach(async () => {
mockI18nService = mock<I18nService>();
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();
});
});
});

View file

@ -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<string>();
/**
* Icon to show before the title
*/
readonly icon = input<string>();
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) : "";
});
}

View file

@ -0,0 +1 @@
export { DesktopHeaderComponent } from "./desktop-header.component";

View file

@ -1,40 +1,31 @@
<div id="sends" class="vault">
<div class="send-items-panel tw-w-2/5">
<div class="content">
<!-- Header with Send title and New button -->
<div
class="tw-flex tw-items-center tw-justify-between tw-px-4 tw-py-3 tw-border-b tw-border-secondary-300"
>
<h1 class="tw-text-base tw-font-semibold tw-m-0">{{ "send" | i18n }}</h1>
@if (!disableSend()) {
<tools-new-send-dropdown-v2
[buttonType]="'primary'"
(addSend)="addSend($event)"
></tools-new-send-dropdown-v2>
}
</div>
<!-- Header with Send title and New button -->
<app-header>
@if (!disableSend()) {
<tools-new-send-dropdown-v2 buttonType="primary" (addSend)="addSend($event)" />
}
</app-header>
<div class="tw-my-4 tw-px-4">
<!-- Send List Component -->
<div class="tw-my-4 tw-px-4">
<tools-send-list
[sends]="filteredSends()"
[loading]="loading()"
[disableSend]="disableSend()"
[listState]="listState()"
[searchText]="currentSearchText()"
(editSend)="onEditSend($event)"
(copySend)="onCopySend($event)"
(deleteSend)="onDeleteSend($event)"
(removePassword)="onRemovePassword($event)"
>
<tools-new-send-dropdown-v2
slot="empty-button"
[hideIcon]="true"
buttonType="primary"
(addSend)="addSend($event)"
/>
</tools-send-list>
</div>
<tools-send-list
[sends]="filteredSends()"
[loading]="loading()"
[disableSend]="disableSend()"
[listState]="listState()"
[searchText]="currentSearchText()"
(editSend)="onEditSend($event)"
(copySend)="onCopySend($event)"
(deleteSend)="onDeleteSend($event)"
(removePassword)="onRemovePassword($event)"
>
<tools-new-send-dropdown-v2
slot="empty-button"
[hideIcon]="true"
buttonType="primary"
(addSend)="addSend($event)"
/>
</tools-send-list>
</div>
</div>

View file

@ -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<MessagingService>() },
{ provide: ConfigService, useValue: mock<ConfigService>() },
{
provide: ActivatedRoute,
useValue: {
data: of({}),
},
},
],
}).compileComponents();

View file

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

View file

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