import { ChangeDetectorRef, Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; import { Subject, filter, of, switchMap, tap } from "rxjs"; import { Icon } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Translation } from "../dialog"; import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service"; import { AnonLayoutComponent, AnonLayoutMaxWidth } from "./anon-layout.component"; export interface AnonLayoutWrapperData { /** * The optional title of the page. * If a string is provided, it will be presented as is (ex: Organization name) * If a Translation object (supports placeholders) is provided, it will be translated */ pageTitle?: string | Translation | null; /** * The optional subtitle of the page. * If a string is provided, it will be presented as is (ex: user's email) * If a Translation object (supports placeholders) is provided, it will be translated */ pageSubtitle?: string | Translation | null; /** * The icon to display on the page. Pass null to hide the icon. */ pageIcon: Icon | null; /** * Optional flag to either show the optional environment selector (false) or just a readonly hostname (true). */ showReadonlyHostname?: boolean; /** * Optional flag to set the max-width of the page. Defaults to 'md' if not provided. */ maxWidth?: AnonLayoutMaxWidth; /** * Hide the card that wraps the default content. Defaults to false. */ hideCardWrapper?: boolean; /** * Hides the background illustration. Defaults to false. */ hideBackgroundIllustration?: boolean; } // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "anon-layout-wrapper.component.html", imports: [AnonLayoutComponent, RouterModule], }) export class AnonLayoutWrapperComponent implements OnInit { private destroy$ = new Subject(); protected pageTitle?: string | null; protected pageSubtitle?: string | null; protected pageIcon: Icon | null = null; protected showReadonlyHostname?: boolean | null; protected maxWidth?: AnonLayoutMaxWidth | null; protected hideCardWrapper?: boolean | null; protected hideBackgroundIllustration?: boolean | null; constructor( private router: Router, private route: ActivatedRoute, private i18nService: I18nService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, private changeDetectorRef: ChangeDetectorRef, ) {} private readonly destroyRef = inject(DestroyRef); ngOnInit(): void { // Set the initial page data on load 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), // reset page data on page changes tap(() => this.resetPageData()), switchMap(() => this.route.firstChild?.data || of(null)), takeUntilDestroyed(this.destroyRef), ) .subscribe((firstChildRouteData: Data | null) => { this.setAnonLayoutWrapperDataFromRouteData(firstChildRouteData); }); } private setAnonLayoutWrapperDataFromRouteData(firstChildRouteData?: Data | null) { if (!firstChildRouteData) { return; } if (firstChildRouteData["pageTitle"] !== undefined) { this.pageTitle = this.handleStringOrTranslation(firstChildRouteData["pageTitle"]); } if (firstChildRouteData["pageSubtitle"] !== undefined) { this.pageSubtitle = this.handleStringOrTranslation(firstChildRouteData["pageSubtitle"]); } if (firstChildRouteData["pageIcon"] !== undefined) { this.pageIcon = firstChildRouteData["pageIcon"]; } this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); this.maxWidth = firstChildRouteData["maxWidth"]; this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]); this.hideBackgroundIllustration = Boolean(firstChildRouteData["hideBackgroundIllustration"]); } private listenForServiceDataChanges() { this.anonLayoutWrapperDataService .anonLayoutWrapperData$() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((data: Partial) => { this.setAnonLayoutWrapperData(data); }); } private setAnonLayoutWrapperData(data: Partial) { if (!data) { return; } // Null emissions are used to reset the page data as all fields are optional. if (data.pageTitle !== undefined) { this.pageTitle = data.pageTitle !== null ? this.handleStringOrTranslation(data.pageTitle) : null; } if (data.pageSubtitle !== undefined) { this.pageSubtitle = data.pageSubtitle !== null ? this.handleStringOrTranslation(data.pageSubtitle) : null; } if (data.pageIcon !== undefined) { this.pageIcon = data.pageIcon !== null ? data.pageIcon : null; } if (data.showReadonlyHostname !== undefined) { this.showReadonlyHostname = data.showReadonlyHostname; } if (data.hideCardWrapper !== undefined) { this.hideCardWrapper = data.hideCardWrapper; } if (data.hideBackgroundIllustration !== undefined) { this.hideBackgroundIllustration = data.hideBackgroundIllustration; } if (data.maxWidth !== undefined) { this.maxWidth = data.maxWidth; } // Manually fire change detection to avoid ExpressionChangedAfterItHasBeenCheckedError // when setting the page data from a service this.changeDetectorRef.detectChanges(); } private handleStringOrTranslation(value: string | Translation): string { if (typeof value === "string") { // If it's a string, return it as is return value; } // If it's a Translation object, translate it return this.i18nService.t(value.key, ...(value.placeholders ?? [])); } private resetPageData() { this.pageTitle = null; this.pageSubtitle = null; this.pageIcon = null; this.showReadonlyHostname = null; this.maxWidth = null; this.hideCardWrapper = null; this.hideBackgroundIllustration = null; } }