From f0f8ec2ca2cf9c3023b877f38998e17a96f2763a Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 18 Nov 2025 17:59:23 -0500 Subject: [PATCH] wip: add dumb header component to CL; use within app-header --- .../src/app/layouts/header/header.module.ts | 9 +- .../layouts/header/web-header.component.html | 187 ++++++------ .../layouts/header/web-header.component.ts | 79 +++--- .../app/layouts/header/web-header.stories.ts | 268 ------------------ .../src/header/header.component.html | 35 +++ .../components/src/header/header.component.ts | 19 ++ libs/components/src/header/header.stories.ts | 190 +++++++++++++ libs/components/src/header/index.ts | 1 + libs/components/src/index.ts | 1 + 9 files changed, 365 insertions(+), 424 deletions(-) delete mode 100644 apps/web/src/app/layouts/header/web-header.stories.ts create mode 100644 libs/components/src/header/header.component.html create mode 100644 libs/components/src/header/header.component.ts create mode 100644 libs/components/src/header/header.stories.ts create mode 100644 libs/components/src/header/index.ts diff --git a/apps/web/src/app/layouts/header/header.module.ts b/apps/web/src/app/layouts/header/header.module.ts index 7518c83a885..cd21c65f6ef 100644 --- a/apps/web/src/app/layouts/header/header.module.ts +++ b/apps/web/src/app/layouts/header/header.module.ts @@ -1,16 +1,9 @@ import { NgModule } from "@angular/core"; -import { BannerModule } from "@bitwarden/components"; - -import { DynamicAvatarComponent } from "../../components/dynamic-avatar.component"; -import { SharedModule } from "../../shared"; -import { ProductSwitcherModule } from "../product-switcher/product-switcher.module"; - import { WebHeaderComponent } from "./web-header.component"; @NgModule({ - imports: [SharedModule, DynamicAvatarComponent, ProductSwitcherModule, BannerModule], - declarations: [WebHeaderComponent], + imports: [WebHeaderComponent], exports: [WebHeaderComponent], }) export class HeaderModule {} diff --git a/apps/web/src/app/layouts/header/web-header.component.html b/apps/web/src/app/layouts/header/web-header.component.html index 4b833e771dd..3ceeb895c0c 100644 --- a/apps/web/src/app/layouts/header/web-header.component.html +++ b/apps/web/src/app/layouts/header/web-header.component.html @@ -1,110 +1,87 @@ -
-
-
+@let routeData = routeData$ | async; +@if (routeData) { + + -

+ + + + + @let account = account$ | async; + @if (account) { +

-
-
-
- - - - + + +
+
- - - -
-
- -
- {{ "loggedInAs" | i18n }} - - {{ account | userName }} - -
-
- - - - - {{ hostname }} - - - - - - - - {{ "accountSettings" | i18n }} - - - - {{ "getHelp" | i18n }} - - - - {{ "getApps" | i18n }} - - - - - - +
+ {{ "loggedInAs" | i18n }} + + {{ account | userName }} +
- - -
-
- -
-
-
-
- -
-
+ + + @if (selfHosted) { + + + {{ hostname }} + + } + + + + + + {{ "accountSettings" | i18n }} + + + + {{ "getHelp" | i18n }} + + + + {{ "getApps" | i18n }} + + + + + @if (canLock$ | async) { + + } + + + + + } + + + + + + + + + + + + + +} diff --git a/apps/web/src/app/layouts/header/web-header.component.ts b/apps/web/src/app/layouts/header/web-header.component.ts index 694ee5c4ae9..14bc6186757 100644 --- a/apps/web/src/app/layouts/header/web-header.component.ts +++ b/apps/web/src/app/layouts/header/web-header.component.ts @@ -1,69 +1,62 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { map, Observable } from "rxjs"; -import { User } from "@bitwarden/angular/pipes/user-name.pipe"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { VaultTimeoutAction, VaultTimeoutSettingsService, } from "@bitwarden/common/key-management/vault-timeout"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { UserId } from "@bitwarden/common/types/guid"; +import { BannerModule, HeaderComponent } from "@bitwarden/components"; + +import { DynamicAvatarComponent } from "../../components/dynamic-avatar.component"; +import { SharedModule } from "../../shared"; +import { ProductSwitcherModule } from "../product-switcher/product-switcher.module"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-header", templateUrl: "./web-header.component.html", - standalone: false, + imports: [ + SharedModule, + DynamicAvatarComponent, + ProductSwitcherModule, + BannerModule, + HeaderComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class WebHeaderComponent { + private route = inject(ActivatedRoute); + private platformUtilsService = inject(PlatformUtilsService); + private vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService); + private messagingService = inject(MessagingService); + private accountService = inject(AccountService); + /** * Custom title that overrides the route data `titleId` */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() title: string; + readonly title = input(); /** * Icon to show before the title */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() icon: string; + readonly icon = input(); - protected routeData$: Observable<{ titleId: string }>; - protected account$: Observable; - protected canLock$: Observable; - protected selfHosted: boolean; - protected hostname = location.hostname; - - constructor( - private route: ActivatedRoute, - private platformUtilsService: PlatformUtilsService, - private vaultTimeoutSettingsService: VaultTimeoutSettingsService, - private messagingService: MessagingService, - private accountService: AccountService, - ) { - this.routeData$ = this.route.data.pipe( - map((params) => { - return { - titleId: params.titleId, - }; - }), - ); - - this.selfHosted = this.platformUtilsService.isSelfHost(); - - this.account$ = this.accountService.activeAccount$; - this.canLock$ = this.vaultTimeoutSettingsService - .availableVaultTimeoutActions$() - .pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock))); - } + protected routeData$: Observable<{ titleId: string }> = this.route.data.pipe( + map((params) => { + return { + titleId: params.titleId, + }; + }), + ); + protected account$: Observable = this.accountService.activeAccount$; + protected canLock$: Observable = this.vaultTimeoutSettingsService + .availableVaultTimeoutActions$() + .pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock))); + protected readonly selfHosted = this.platformUtilsService.isSelfHost(); + protected readonly hostname = globalThis.location.hostname; protected lock() { this.messagingService.send("lockVault"); diff --git a/apps/web/src/app/layouts/header/web-header.stories.ts b/apps/web/src/app/layouts/header/web-header.stories.ts deleted file mode 100644 index 88c98f01e6c..00000000000 --- a/apps/web/src/app/layouts/header/web-header.stories.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component, importProvidersFrom, Injectable, Input } from "@angular/core"; -import { RouterModule } from "@angular/router"; -import { - applicationConfig, - componentWrapperDecorator, - Meta, - moduleMetadata, - StoryObj, -} from "@storybook/angular"; -import { BehaviorSubject, combineLatest, map, of } from "rxjs"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { - VaultTimeoutAction, - VaultTimeoutSettingsService, -} from "@bitwarden/common/key-management/vault-timeout"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { - AvatarModule, - BreadcrumbsModule, - ButtonModule, - IconButtonModule, - IconModule, - InputModule, - MenuModule, - NavigationModule, - TabsModule, - TypographyModule, -} from "@bitwarden/components"; - -import { DynamicAvatarComponent } from "../../components/dynamic-avatar.component"; -import { PreloadedEnglishI18nModule } from "../../core/tests"; -import { WebHeaderComponent } from "../header/web-header.component"; - -import { WebLayoutMigrationBannerService } from "./web-layout-migration-banner.service"; - -@Injectable({ - providedIn: "root", -}) -class MockStateService { - activeAccount$ = new BehaviorSubject("1").asObservable(); - accounts$ = new BehaviorSubject({ "1": { profile: { name: "Foo" } } }).asObservable(); -} - -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@Component({ - selector: "product-switcher", - template: ``, - standalone: false, -}) -class MockProductSwitcherComponent {} - -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@Component({ - selector: "dynamic-avatar", - template: ``, - imports: [CommonModule, AvatarModule], -}) -class MockDynamicAvatarComponent implements Partial { - protected name$ = combineLatest([ - this.stateService.accounts$, - this.stateService.activeAccount$, - ]).pipe( - map( - ([accounts, activeAccount]) => accounts[activeAccount as keyof typeof accounts].profile.name, - ), - ); - - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() - text?: string; - - constructor(private stateService: MockStateService) {} -} - -export default { - title: "Web/Header", - component: WebHeaderComponent, - decorators: [ - componentWrapperDecorator( - (story) => `
${story}
`, - ), - moduleMetadata({ - imports: [ - JslibModule, - AvatarModule, - BreadcrumbsModule, - ButtonModule, - IconButtonModule, - IconModule, - InputModule, - MenuModule, - TabsModule, - TypographyModule, - NavigationModule, - MockDynamicAvatarComponent, - ], - declarations: [WebHeaderComponent, MockProductSwitcherComponent], - providers: [ - { provide: StateService, useClass: MockStateService }, - { - provide: AccountService, - useValue: { - activeAccount$: of({ - name: "Foobar Warden", - }), - } as Partial, - }, - { - provide: WebLayoutMigrationBannerService, - useValue: { - showBanner$: of(false), - } as Partial, - }, - { - provide: PlatformUtilsService, - useValue: { - isSelfHost() { - return false; - }, - } as Partial, - }, - { - provide: VaultTimeoutSettingsService, - useValue: { - availableVaultTimeoutActions$() { - return new BehaviorSubject([VaultTimeoutAction.Lock]).asObservable(); - }, - } as Partial, - }, - { - provide: MessagingService, - useValue: { - send: (...args: any[]) => { - // eslint-disable-next-line no-console - console.log("MessagingService.send", args); - }, - } as Partial, - }, - ], - }), - applicationConfig({ - providers: [ - importProvidersFrom(RouterModule.forRoot([], { useHash: true })), - importProvidersFrom(PreloadedEnglishI18nModule), - ], - }), - ], -} as Meta; - -type Story = StoryObj; - -export const KitchenSink: Story = { - render: (args) => ({ - props: args, - template: ` - - - Foo - Bar - - - - - - Foo - Bar - - - `, - }), -}; - -export const Basic: Story = { - render: (args: any) => ({ - props: args, - template: ` - - `, - }), -}; - -export const WithLongTitle: Story = { - render: (arg: any) => ({ - props: arg, - template: ` - - - - `, - }), -}; - -export const WithBreadcrumbs: Story = { - render: (args: any) => ({ - props: args, - template: ` - - - Foo - Bar - - - `, - }), -}; - -export const WithSearch: Story = { - render: (args: any) => ({ - props: args, - template: ` - - - - `, - }), -}; - -export const WithSecondaryContent: Story = { - render: (args) => ({ - props: args, - template: ` - - - - `, - }), -}; - -export const WithTabs: Story = { - render: (args) => ({ - props: args, - template: ` - - - Foo - Bar - - - `, - }), -}; - -export const WithTitleSuffixComponent: Story = { - render: (args) => ({ - props: args, - template: ` - - - - `, - }), -}; diff --git a/libs/components/src/header/header.component.html b/libs/components/src/header/header.component.html new file mode 100644 index 00000000000..992dcbea4a7 --- /dev/null +++ b/libs/components/src/header/header.component.html @@ -0,0 +1,35 @@ +
+
+
+ +

+
+ @if (icon()) { + + } + + {{ title() }} +
+
+

+
+
+
+ +
+
+ +
+
+
+
+ +
+
diff --git a/libs/components/src/header/header.component.ts b/libs/components/src/header/header.component.ts new file mode 100644 index 00000000000..8d95735b926 --- /dev/null +++ b/libs/components/src/header/header.component.ts @@ -0,0 +1,19 @@ +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; + +@Component({ + selector: "bit-header", + templateUrl: "./header.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, +}) +export class HeaderComponent { + /** + * Custom title that overrides the route data `titleId` + */ + readonly title = input.required(); + + /** + * Icon to show before the title + */ + readonly icon = input(); +} diff --git a/libs/components/src/header/header.stories.ts b/libs/components/src/header/header.stories.ts new file mode 100644 index 00000000000..110b2b411b7 --- /dev/null +++ b/libs/components/src/header/header.stories.ts @@ -0,0 +1,190 @@ +import { importProvidersFrom } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { + applicationConfig, + componentWrapperDecorator, + Meta, + moduleMetadata, + StoryObj, +} from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + AvatarModule, + BreadcrumbsModule, + ButtonModule, + IconButtonModule, + IconModule, + InputModule, + MenuModule, + NavigationModule, + TabsModule, + TypographyModule, +} from "@bitwarden/components"; + +import { I18nMockService } from "../utils"; + +import { HeaderComponent } from "./header.component"; + +export default { + title: "Component Library/Header", + component: HeaderComponent, + decorators: [ + componentWrapperDecorator( + (story) => `
${story}
`, + ), + moduleMetadata({ + imports: [ + HeaderComponent, + + AvatarModule, + BreadcrumbsModule, + ButtonModule, + IconButtonModule, + IconModule, + InputModule, + MenuModule, + NavigationModule, + TabsModule, + TypographyModule, + ], + }), + applicationConfig({ + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + moreBreadcrumbs: "More breadcrumbs", + loading: "Loading", + }); + }, + }, + importProvidersFrom( + RouterModule.forRoot( + [ + { path: "", redirectTo: "foo", pathMatch: "full" }, + { path: "foo", component: HeaderComponent }, + { path: "bar", component: HeaderComponent }, + ], + { useHash: true }, + ), + ), + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const KitchenSink: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + Foo + Bar + + + + + + + + Foo + Bar + + + `, + }), +}; + +export const Basic: Story = { + render: (args: any) => ({ + props: args, + template: /*html*/ ` + + `, + }), +}; + +export const WithLongTitle: Story = { + render: (arg: any) => ({ + props: arg, + template: /*html*/ ` + + + + `, + }), +}; + +export const WithBreadcrumbs: Story = { + render: (args: any) => ({ + props: args, + template: /*html*/ ` + + + Foo + Bar + + + `, + }), +}; + +export const WithSearch: Story = { + render: (args: any) => ({ + props: args, + template: /*html*/ ` + + + + `, + }), +}; + +export const WithSecondaryContent: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + `, + }), +}; + +export const WithTabs: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + Foo + Bar + + + `, + }), +}; + +export const WithTitleSuffixComponent: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + `, + }), +}; diff --git a/libs/components/src/header/index.ts b/libs/components/src/header/index.ts new file mode 100644 index 00000000000..c2d38d375ed --- /dev/null +++ b/libs/components/src/header/index.ts @@ -0,0 +1 @@ +export * from "./header.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 5346747d3b2..410a96f1cb3 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -19,6 +19,7 @@ export * from "./dialog"; export * from "./disclosure"; export * from "./drawer"; export * from "./form-field"; +export * from "./header"; export * from "./icon-button"; export * from "./icon"; export * from "./icon-tile";