From 96648b48978465ae1a27e8c49239ac1daf930b66 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Thu, 25 Jul 2024 16:16:54 -0400 Subject: [PATCH] Auth/PM-9603 - AnonLayoutWrapper Dynamic content support (#10216) * PM-9603 - WIP - Untested DefaultAnonLayoutWrapperDataService * PM-9603 - DefaultAnonLayoutWrapperSvc needs constructor * PM-9603 - Good progress on getting storybook setup for the anon-layout-wrapper component - having issues with getting dummy component to display. * PM-9603 - AnonLayoutWrapper Story working with default and dynamic content. * PM-9603 - Tweak verbiage * PM-9603 - Tweak stories; add mdx * PM-9603 - Export AnonLayoutWrapperDataService and DefaultAnonLayoutWrapperDataService from libs/auth and wire up as default implementation in jslib-services.module * PM-9603 - Address PR feedback --- .../src/services/jslib-services.module.ts | 7 + .../anon-layout-wrapper-data.service.ts | 20 ++ .../anon-layout-wrapper.component.ts | 53 +++- .../anon-layout/anon-layout-wrapper.mdx | 28 +++ .../anon-layout-wrapper.stories.ts | 237 ++++++++++++++++++ ...efault-anon-layout-wrapper-data.service.ts | 16 ++ libs/auth/src/angular/index.ts | 2 + 7 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 libs/auth/src/angular/anon-layout/anon-layout-wrapper-data.service.ts create mode 100644 libs/auth/src/angular/anon-layout/anon-layout-wrapper.mdx create mode 100644 libs/auth/src/angular/anon-layout/anon-layout-wrapper.stories.ts create mode 100644 libs/auth/src/angular/anon-layout/default-anon-layout-wrapper-data.service.ts diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 3294e59751..8ad1e7d20c 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -6,6 +6,8 @@ import { DefaultSetPasswordJitService, RegistrationFinishService as RegistrationFinishServiceAbstraction, DefaultRegistrationFinishService, + AnonLayoutWrapperDataService, + DefaultAnonLayoutWrapperDataService, } from "@bitwarden/auth/angular"; import { AuthRequestServiceAbstraction, @@ -1286,6 +1288,11 @@ const safeProviders: SafeProvider[] = [ useClass: RegisterRouteService, deps: [ConfigService], }), + safeProvider({ + provide: AnonLayoutWrapperDataService, + useClass: DefaultAnonLayoutWrapperDataService, + deps: [], + }), safeProvider({ provide: RegistrationFinishServiceAbstraction, useClass: DefaultRegistrationFinishService, diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper-data.service.ts b/libs/auth/src/angular/anon-layout/anon-layout-wrapper-data.service.ts new file mode 100644 index 0000000000..4135e6e0fd --- /dev/null +++ b/libs/auth/src/angular/anon-layout/anon-layout-wrapper-data.service.ts @@ -0,0 +1,20 @@ +import { Observable } from "rxjs"; + +import { AnonLayoutWrapperData } from "./anon-layout-wrapper.component"; + +/** + * A simple data service to allow any child components of the AnonLayoutWrapperComponent to override + * page route data and dynamically control the data fed into the AnonLayoutComponent via the AnonLayoutWrapperComponent. + */ +export abstract class AnonLayoutWrapperDataService { + /** + * + * @param data - The data to set on the AnonLayoutWrapperComponent to feed into the AnonLayoutComponent. + */ + abstract setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void; + + /** + * Reactively gets the current AnonLayoutWrapperData. + */ + abstract anonLayoutWrapperData$(): Observable; +} diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts index 30ef5c1f76..0bedf221e0 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts @@ -6,6 +6,8 @@ import { AnonLayoutComponent } from "@bitwarden/auth/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Icon } from "@bitwarden/components"; +import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service"; + export interface AnonLayoutWrapperData { pageTitle?: string; pageSubtitle?: string; @@ -30,13 +32,18 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { private router: Router, private route: ActivatedRoute, private i18nService: I18nService, + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, ) {} ngOnInit(): void { // Set the initial page data on load - this.setAnonLayoutWrapperData(this.route.snapshot.firstChild?.data); - + this.setAnonLayoutWrapperDataFromRouteData(this.route.snapshot.firstChild?.data); // Listen for page changes and update the page data appropriately + this.listenForPageDataChanges(); + this.listenForServiceDataChanges(); + } + + private listenForPageDataChanges() { this.router.events .pipe( filter((event) => event instanceof NavigationEnd), @@ -46,11 +53,11 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { takeUntil(this.destroy$), ) .subscribe((firstChildRouteData: Data | null) => { - this.setAnonLayoutWrapperData(firstChildRouteData); + this.setAnonLayoutWrapperDataFromRouteData(firstChildRouteData); }); } - private setAnonLayoutWrapperData(firstChildRouteData: Data | null) { + private setAnonLayoutWrapperDataFromRouteData(firstChildRouteData: Data | null) { if (!firstChildRouteData) { return; } @@ -63,8 +70,42 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { this.pageSubtitle = this.i18nService.t(firstChildRouteData["pageSubtitle"]); } - this.pageIcon = firstChildRouteData["pageIcon"]; - this.showReadonlyHostname = firstChildRouteData["showReadonlyHostname"]; + if (firstChildRouteData["pageIcon"] !== undefined) { + this.pageIcon = firstChildRouteData["pageIcon"]; + } + + this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); + } + + private listenForServiceDataChanges() { + this.anonLayoutWrapperDataService + .anonLayoutWrapperData$() + .pipe(takeUntil(this.destroy$)) + .subscribe((data: AnonLayoutWrapperData) => { + this.setAnonLayoutWrapperData(data); + }); + } + + private setAnonLayoutWrapperData(data: AnonLayoutWrapperData) { + if (!data) { + return; + } + + if (data.pageTitle) { + this.pageTitle = this.i18nService.t(data.pageTitle); + } + + if (data.pageSubtitle) { + this.pageSubtitle = this.i18nService.t(data.pageSubtitle); + } + + if (data.pageIcon) { + this.pageIcon = data.pageIcon; + } + + if (data.showReadonlyHostname) { + this.showReadonlyHostname = data.showReadonlyHostname; + } } private resetPageData() { diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.mdx b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.mdx new file mode 100644 index 0000000000..a218eaa149 --- /dev/null +++ b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.mdx @@ -0,0 +1,28 @@ +import { Meta, Story, Controls } from "@storybook/addon-docs"; + +import * as stories from "./anon-layout-wrapper.stories"; + + + +# Anon Layout Wrapper + +NOTE: These stories will treat "Light & Dark" mode as "Light" mode. This is done to avoid a bug with +the way that we render the same component twice in the same iframe and how that interacts with the +`router-outlet`. + +## Anon Layout Wrapper Component + +The auth owned `AnonLayoutWrapperComponent` orchestrates routing configuration data and feeds it +into the `AnonLayoutComponent`. See the `Anon Layout` storybook for full documentation on how to use +the `AnonLayoutWrapperComponent`. + +## Default Example with all 3 outlets used + + + +## Dynamic Anon Layout Wrapper Content Example + +This example demonstrates a child component using the `DefaultAnonLayoutWrapperDataService` to +dynamically set the content of the `AnonLayoutWrapperComponent`. + + diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.stories.ts b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.stories.ts new file mode 100644 index 0000000000..80fabe4061 --- /dev/null +++ b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.stories.ts @@ -0,0 +1,237 @@ +import { importProvidersFrom, Component } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; +import { + Meta, + StoryObj, + applicationConfig, + componentWrapperDecorator, + moduleMetadata, +} from "@storybook/angular"; +import { of } from "rxjs"; + +import { ClientType } from "@bitwarden/common/enums"; +import { + EnvironmentService, + Environment, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ThemeType } from "@bitwarden/common/platform/enums"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { ButtonModule } from "@bitwarden/components"; + +import { PreloadedEnglishI18nModule } from "../../../../../apps/web/src/app/core/tests"; +import { LockIcon } from "../icons"; +import { RegistrationCheckEmailIcon } from "../icons/registration-check-email.icon"; + +import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service"; +import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "./anon-layout-wrapper.component"; +import { DefaultAnonLayoutWrapperDataService } from "./default-anon-layout-wrapper-data.service"; + +export default { + title: "Auth/Anon Layout Wrapper", + component: AnonLayoutWrapperComponent, +} as Meta; + +const decorators = (options: { + components: any[]; + routes: Routes; + applicationVersion?: string; + clientType?: ClientType; + hostName?: string; + themeType?: ThemeType; +}) => { + return [ + componentWrapperDecorator( + /** + * Applying a CSS transform makes a `position: fixed` element act like it is `position: relative` + * https://github.com/storybookjs/storybook/issues/8011#issue-490251969 + */ + (story) => { + return /* HTML */ `
${story}
`; + }, + ({ globals }) => { + /** + * avoid a bug with the way that we render the same component twice in the same iframe and how + * that interacts with the router-outlet + */ + const themeOverride = globals["theme"] === "both" ? "light" : globals["theme"]; + return { theme: themeOverride }; + }, + ), + moduleMetadata({ + declarations: options.components, + imports: [RouterModule, ButtonModule], + providers: [ + { + provide: AnonLayoutWrapperDataService, + useClass: DefaultAnonLayoutWrapperDataService, + }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getHostname: () => options.hostName || "storybook.bitwarden.com", + } as Partial), + } as Partial, + }, + { + provide: PlatformUtilsService, + useValue: { + getApplicationVersion: () => + Promise.resolve(options.applicationVersion || "FAKE_APP_VERSION"), + getClientType: () => options.clientType || ClientType.Web, + } as Partial, + }, + { + provide: ThemeStateService, + useValue: { + selectedTheme$: of(options.themeType || ThemeType.Light), + } as Partial, + }, + ], + }), + applicationConfig({ + providers: [ + importProvidersFrom(RouterModule.forRoot(options.routes)), + importProvidersFrom(PreloadedEnglishI18nModule), + ], + }), + ]; +}; + +type Story = StoryObj; + +// Default Example + +@Component({ + selector: "bit-default-primary-outlet-example-component", + template: "

Primary Outlet Example:
your primary component goes here

", +}) +export class DefaultPrimaryOutletExampleComponent {} + +@Component({ + selector: "bit-default-secondary-outlet-example-component", + template: "

Secondary Outlet Example:
your secondary component goes here

", +}) +export class DefaultSecondaryOutletExampleComponent {} + +@Component({ + selector: "bit-default-env-selector-outlet-example-component", + template: "

Env Selector Outlet Example:
your env selector component goes here

", +}) +export class DefaultEnvSelectorOutletExampleComponent {} + +export const DefaultContentExample: Story = { + render: (args) => ({ + props: args, + template: "", + }), + decorators: decorators({ + components: [ + DefaultPrimaryOutletExampleComponent, + DefaultSecondaryOutletExampleComponent, + DefaultEnvSelectorOutletExampleComponent, + ], + routes: [ + { + path: "**", + redirectTo: "default-example", + pathMatch: "full", + }, + { + path: "", + component: AnonLayoutWrapperComponent, + children: [ + { + path: "default-example", + data: {}, + children: [ + { + path: "", + component: DefaultPrimaryOutletExampleComponent, + }, + { + path: "", + component: DefaultSecondaryOutletExampleComponent, + outlet: "secondary", + }, + { + path: "", + component: DefaultEnvSelectorOutletExampleComponent, + outlet: "environment-selector", + }, + ], + }, + ], + }, + ], + }), +}; + +// Dynamic Content Example +const initialData: AnonLayoutWrapperData = { + pageTitle: "setAStrongPassword", + pageSubtitle: "finishCreatingYourAccountBySettingAPassword", + pageIcon: LockIcon, +}; + +const changedData: AnonLayoutWrapperData = { + pageTitle: "enterpriseSingleSignOn", + pageSubtitle: "checkYourEmail", + pageIcon: RegistrationCheckEmailIcon, +}; + +@Component({ + selector: "bit-dynamic-content-example-component", + template: ` + + `, +}) +export class DynamicContentExampleComponent { + initialData = true; + + constructor(private anonLayoutWrapperDataService: AnonLayoutWrapperDataService) {} + + toggleData() { + if (this.initialData) { + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData(changedData); + } else { + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData(initialData); + } + + this.initialData = !this.initialData; + } +} + +export const DynamicContentExample: Story = { + render: (args) => ({ + props: args, + template: "", + }), + decorators: decorators({ + components: [DynamicContentExampleComponent], + routes: [ + { + path: "**", + redirectTo: "dynamic-content-example", + pathMatch: "full", + }, + { + path: "", + component: AnonLayoutWrapperComponent, + children: [ + { + path: "dynamic-content-example", + data: initialData, + children: [ + { + path: "", + component: DynamicContentExampleComponent, + }, + ], + }, + ], + }, + ], + }), +}; diff --git a/libs/auth/src/angular/anon-layout/default-anon-layout-wrapper-data.service.ts b/libs/auth/src/angular/anon-layout/default-anon-layout-wrapper-data.service.ts new file mode 100644 index 0000000000..43637d481f --- /dev/null +++ b/libs/auth/src/angular/anon-layout/default-anon-layout-wrapper-data.service.ts @@ -0,0 +1,16 @@ +import { Observable, Subject } from "rxjs"; + +import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service"; +import { AnonLayoutWrapperData } from "./anon-layout-wrapper.component"; + +export class DefaultAnonLayoutWrapperDataService implements AnonLayoutWrapperDataService { + private anonLayoutWrapperDataSubject = new Subject(); + + setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void { + this.anonLayoutWrapperDataSubject.next(data); + } + + anonLayoutWrapperData$(): Observable { + return this.anonLayoutWrapperDataSubject.asObservable(); + } +} diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index f455f42eef..f6a9ffde55 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -8,6 +8,8 @@ export * from "./icons"; // anon layout export * from "./anon-layout/anon-layout.component"; export * from "./anon-layout/anon-layout-wrapper.component"; +export * from "./anon-layout/anon-layout-wrapper-data.service"; +export * from "./anon-layout/default-anon-layout-wrapper-data.service"; // fingerprint dialog export * from "./fingerprint-dialog/fingerprint-dialog.component";