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
This commit is contained in:
Jared Snider 2024-07-25 16:16:54 -04:00 committed by GitHub
parent 0bf0d1ac96
commit 96648b4897
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 357 additions and 6 deletions

View File

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

View File

@ -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<AnonLayoutWrapperData>;
}

View File

@ -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() {

View File

@ -0,0 +1,28 @@
import { Meta, Story, Controls } from "@storybook/addon-docs";
import * as stories from "./anon-layout-wrapper.stories";
<Meta of={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
<Story of={stories.DefaultContentExample} />
## Dynamic Anon Layout Wrapper Content Example
This example demonstrates a child component using the `DefaultAnonLayoutWrapperDataService` to
dynamically set the content of the `AnonLayoutWrapperComponent`.
<Story of={stories.DynamicContentExample} />

View File

@ -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 */ `<div class="tw-scale-100 ">${story}</div>`;
},
({ 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<Environment>),
} as Partial<EnvironmentService>,
},
{
provide: PlatformUtilsService,
useValue: {
getApplicationVersion: () =>
Promise.resolve(options.applicationVersion || "FAKE_APP_VERSION"),
getClientType: () => options.clientType || ClientType.Web,
} as Partial<PlatformUtilsService>,
},
{
provide: ThemeStateService,
useValue: {
selectedTheme$: of(options.themeType || ThemeType.Light),
} as Partial<ThemeStateService>,
},
],
}),
applicationConfig({
providers: [
importProvidersFrom(RouterModule.forRoot(options.routes)),
importProvidersFrom(PreloadedEnglishI18nModule),
],
}),
];
};
type Story = StoryObj<AnonLayoutWrapperComponent>;
// Default Example
@Component({
selector: "bit-default-primary-outlet-example-component",
template: "<p>Primary Outlet Example: <br> your primary component goes here</p>",
})
export class DefaultPrimaryOutletExampleComponent {}
@Component({
selector: "bit-default-secondary-outlet-example-component",
template: "<p>Secondary Outlet Example: <br> your secondary component goes here</p>",
})
export class DefaultSecondaryOutletExampleComponent {}
@Component({
selector: "bit-default-env-selector-outlet-example-component",
template: "<p>Env Selector Outlet Example: <br> your env selector component goes here</p>",
})
export class DefaultEnvSelectorOutletExampleComponent {}
export const DefaultContentExample: Story = {
render: (args) => ({
props: args,
template: "<router-outlet></router-outlet>",
}),
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: `
<button type="button" bitButton buttonType="primary" (click)="toggleData()">Toggle Data</button>
`,
})
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: "<router-outlet></router-outlet>",
}),
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,
},
],
},
],
},
],
}),
};

View File

@ -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<AnonLayoutWrapperData>();
setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void {
this.anonLayoutWrapperDataSubject.next(data);
}
anonLayoutWrapperData$(): Observable<AnonLayoutWrapperData> {
return this.anonLayoutWrapperDataSubject.asObservable();
}
}

View File

@ -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";