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:
parent
0bf0d1ac96
commit
96648b4897
|
@ -6,6 +6,8 @@ import {
|
||||||
DefaultSetPasswordJitService,
|
DefaultSetPasswordJitService,
|
||||||
RegistrationFinishService as RegistrationFinishServiceAbstraction,
|
RegistrationFinishService as RegistrationFinishServiceAbstraction,
|
||||||
DefaultRegistrationFinishService,
|
DefaultRegistrationFinishService,
|
||||||
|
AnonLayoutWrapperDataService,
|
||||||
|
DefaultAnonLayoutWrapperDataService,
|
||||||
} from "@bitwarden/auth/angular";
|
} from "@bitwarden/auth/angular";
|
||||||
import {
|
import {
|
||||||
AuthRequestServiceAbstraction,
|
AuthRequestServiceAbstraction,
|
||||||
|
@ -1286,6 +1288,11 @@ const safeProviders: SafeProvider[] = [
|
||||||
useClass: RegisterRouteService,
|
useClass: RegisterRouteService,
|
||||||
deps: [ConfigService],
|
deps: [ConfigService],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: AnonLayoutWrapperDataService,
|
||||||
|
useClass: DefaultAnonLayoutWrapperDataService,
|
||||||
|
deps: [],
|
||||||
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: RegistrationFinishServiceAbstraction,
|
provide: RegistrationFinishServiceAbstraction,
|
||||||
useClass: DefaultRegistrationFinishService,
|
useClass: DefaultRegistrationFinishService,
|
||||||
|
|
|
@ -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>;
|
||||||
|
}
|
|
@ -6,6 +6,8 @@ import { AnonLayoutComponent } from "@bitwarden/auth/angular";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { Icon } from "@bitwarden/components";
|
import { Icon } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
|
||||||
|
|
||||||
export interface AnonLayoutWrapperData {
|
export interface AnonLayoutWrapperData {
|
||||||
pageTitle?: string;
|
pageTitle?: string;
|
||||||
pageSubtitle?: string;
|
pageSubtitle?: string;
|
||||||
|
@ -30,13 +32,18 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
|
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Set the initial page data on load
|
// 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
|
// Listen for page changes and update the page data appropriately
|
||||||
|
this.listenForPageDataChanges();
|
||||||
|
this.listenForServiceDataChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private listenForPageDataChanges() {
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(
|
.pipe(
|
||||||
filter((event) => event instanceof NavigationEnd),
|
filter((event) => event instanceof NavigationEnd),
|
||||||
|
@ -46,11 +53,11 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||||
takeUntil(this.destroy$),
|
takeUntil(this.destroy$),
|
||||||
)
|
)
|
||||||
.subscribe((firstChildRouteData: Data | null) => {
|
.subscribe((firstChildRouteData: Data | null) => {
|
||||||
this.setAnonLayoutWrapperData(firstChildRouteData);
|
this.setAnonLayoutWrapperDataFromRouteData(firstChildRouteData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private setAnonLayoutWrapperData(firstChildRouteData: Data | null) {
|
private setAnonLayoutWrapperDataFromRouteData(firstChildRouteData: Data | null) {
|
||||||
if (!firstChildRouteData) {
|
if (!firstChildRouteData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -63,8 +70,42 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||||
this.pageSubtitle = this.i18nService.t(firstChildRouteData["pageSubtitle"]);
|
this.pageSubtitle = this.i18nService.t(firstChildRouteData["pageSubtitle"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pageIcon = firstChildRouteData["pageIcon"];
|
if (firstChildRouteData["pageIcon"] !== undefined) {
|
||||||
this.showReadonlyHostname = firstChildRouteData["showReadonlyHostname"];
|
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() {
|
private resetPageData() {
|
||||||
|
|
|
@ -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} />
|
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,8 @@ export * from "./icons";
|
||||||
// anon layout
|
// anon layout
|
||||||
export * from "./anon-layout/anon-layout.component";
|
export * from "./anon-layout/anon-layout.component";
|
||||||
export * from "./anon-layout/anon-layout-wrapper.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
|
// fingerprint dialog
|
||||||
export * from "./fingerprint-dialog/fingerprint-dialog.component";
|
export * from "./fingerprint-dialog/fingerprint-dialog.component";
|
||||||
|
|
Loading…
Reference in New Issue