[PM-25627] convert policy dialogs to drawers (#20078)

* [PM-34804] Implement policy drawer feature for editing policies

* Added `openDrawer` method to `PolicyDialogComponent` and `MultiStepPolicyEditDialogComponent` to support opening dialogs in a drawer format.
* Updated `edit` method in `PoliciesComponent` to conditionally use the drawer based on the `PolicyDrawers` feature flag.
* Introduced `PolicyDrawers` feature flag in `feature-flag.enum.ts` to control the new drawer functionality.

* Enhance policy edit dialogs with drawer support

* Updated `policy-edit-dialog.component.html` and `multi-step-policy-edit-dialog.component.html` to conditionally apply full height class based on drawer state.
* Modified `PolicyEditDialogComponent` to include a host binding for full height when in drawer mode.

* Enhance MultiStepPolicyEditDialogComponent to support full height in drawer mode by adding a host binding for the full height class based on the dialog reference state.

* Enhance PoliciesComponent to manage dialog references and ensure proper cleanup on destroy. Added drawerRef to handle drawer state and close it when the component is destroyed.

* Refactor PoliciesComponent to change drawerRef from readonly to mutable, allowing for potential updates to the dialog reference during component lifecycle.

* Enhance PoliciesComponent tests to support async operations and add new test for drawer functionality. Update policy-edit-dialog components to remove deprecated host bindings. Adjust layout component styles for drawer container.

* Refactor layout component: Simplify drawer container markup by removing unnecessary grid styling.

* Refactor PoliciesComponent: Replace drawerRef with signal for improved state management and update cleanup logic on component destruction.

* Refactor policy edit dialog components: Simplify form markup by integrating dialog properties directly into the form tag and enhancing title template handling for improved readability and maintainability.

* Refactor loading indicator in policy edit dialog to use @if syntax for improved readability

* Refactor policy edit dialog to dynamically set title and subtitle based on current step content
This commit is contained in:
Jared 2026-04-17 12:42:36 -04:00 committed by GitHub
parent dc94f964d2
commit c3c5faf413
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 171 additions and 85 deletions

View File

@ -31,6 +31,10 @@ export interface PolicyDialogComponent {
dialogService: DialogService,
config: DialogConfig<PolicyEditDialogData>,
) => DialogRef<PolicyEditDialogResult>;
openDrawer?: (
dialogService: DialogService,
config: DialogConfig<PolicyEditDialogData>,
) => DialogRef<PolicyEditDialogResult>;
}
/**

View File

@ -496,7 +496,9 @@ describe("PoliciesComponent", () => {
});
describe("edit", () => {
it("should call dialogService.open with correct parameters when no custom dialog is specified", () => {
it("should call dialogService.open with correct parameters when no custom dialog is specified", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
description: "Test Description",
@ -510,7 +512,7 @@ describe("PoliciesComponent", () => {
const openSpy = jest.spyOn(PolicyEditDialogComponent, "open");
component.edit(mockPolicy, mockOrg);
await component.edit(mockPolicy, mockOrg);
expect(openSpy).toHaveBeenCalled();
const callArgs = openSpy.mock.calls[0];
@ -522,7 +524,9 @@ describe("PoliciesComponent", () => {
});
});
it("should call custom dialog open method when specified", () => {
it("should call custom dialog open method when specified", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
const mockDialogRef = { close: jest.fn() };
const mockCustomDialog = {
open: jest.fn().mockReturnValue(mockDialogRef),
@ -540,7 +544,7 @@ describe("PoliciesComponent", () => {
display$: () => of(true),
};
component.edit(mockPolicy, mockOrg);
await component.edit(mockPolicy, mockOrg);
expect(mockCustomDialog.open).toHaveBeenCalled();
const callArgs = mockCustomDialog.open.mock.calls[0];
@ -553,7 +557,9 @@ describe("PoliciesComponent", () => {
expect(PolicyEditDialogComponent.open).not.toHaveBeenCalled();
});
it("should pass organization to dialog", () => {
it("should pass organization to dialog", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
const customOrg = { id: newGuid() as OrganizationId, name: "Custom Org" } as Organization;
const mockPolicy: BasePolicyEditDefinition = {
name: "Test Policy",
@ -568,7 +574,7 @@ describe("PoliciesComponent", () => {
const openSpy = jest.spyOn(PolicyEditDialogComponent, "open");
component.edit(mockPolicy, customOrg);
await component.edit(mockPolicy, customOrg);
expect(openSpy).toHaveBeenCalled();
const callArgs = openSpy.mock.calls[0];
@ -579,5 +585,39 @@ describe("PoliciesComponent", () => {
},
});
});
it("should open drawer when PolicyDrawers flag is enabled and openDrawer is present", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
const mockDrawerRef = { close: jest.fn() };
const mockDrawerDialog = {
open: jest.fn(),
openDrawer: jest.fn().mockReturnValue(mockDrawerRef),
};
const mockPolicy: BasePolicyEditDefinition = {
name: "Drawer Policy",
description: "Drawer Description",
type: PolicyType.TwoFactorAuthentication,
category: PolicyCategory.Authentication,
priority: 10,
component: {} as any,
editDialogComponent: mockDrawerDialog as any,
showDescription: true,
display$: () => of(true),
};
await component.edit(mockPolicy, mockOrg);
expect(mockDrawerDialog.openDrawer).toHaveBeenCalled();
const callArgs = mockDrawerDialog.openDrawer.mock.calls[0];
expect(callArgs[1]).toEqual({
data: {
policy: mockPolicy,
organization: mockOrg,
},
});
expect(mockDrawerDialog.open).not.toHaveBeenCalled();
});
});
});

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core";
import { ChangeDetectionStrategy, Component, DestroyRef, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs";
@ -11,10 +11,16 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { getById } from "@bitwarden/common/platform/misc";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { DialogService, ItemModule, SectionHeaderComponent } from "@bitwarden/components";
import {
DialogRef,
DialogService,
ItemModule,
SectionHeaderComponent,
} from "@bitwarden/components";
import { safeProvider } from "@bitwarden/ui-common";
import { HeaderModule } from "../../../layouts/header/header.module";
@ -37,6 +43,8 @@ import { POLICY_EDIT_REGISTER } from "./policy-register-token";
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PoliciesComponent {
private readonly drawerRef = signal<DialogRef<any> | undefined>(undefined);
private readonly userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
protected readonly organizationId$: Observable<OrganizationId> = this.route.params.pipe(
@ -128,6 +136,7 @@ export class PoliciesComponent {
private readonly destroyRef: DestroyRef,
) {
this.handleLaunchEvent();
this.destroyRef.onDestroy(() => this.drawerRef()?.close());
}
// Handle policies component launch from Event message
@ -142,7 +151,7 @@ export class PoliciesComponent {
if (orgPolicy.id === policyIdFromEvents) {
for (const policy of policies) {
if (policy.type === orgPolicy.type) {
this.edit(policy, organization);
void this.edit(policy, organization);
break;
}
}
@ -156,15 +165,27 @@ export class PoliciesComponent {
.subscribe();
}
edit(policy: BasePolicyEditDefinition, organization: Organization) {
async edit(policy: BasePolicyEditDefinition, organization: Organization) {
const useDrawer = await this.configService.getFeatureFlag(FeatureFlag.PolicyDrawers);
const dialogComponent: PolicyDialogComponent =
policy.editDialogComponent ?? PolicyEditDialogComponent;
dialogComponent.open(this.dialogService, {
data: {
policy: policy,
organization: organization,
},
});
if (useDrawer && dialogComponent.openDrawer) {
this.drawerRef.set(
dialogComponent.openDrawer(this.dialogService, {
data: {
policy: policy,
organization: organization,
},
}),
);
} else {
dialogComponent.open(this.dialogService, {
data: {
policy: policy,
organization: organization,
},
});
}
}
}

View File

@ -1,7 +1,14 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading()" [title]="'editPolicy' | i18n" [subtitle]="policy.name | i18n">
<ng-container bitDialogContent>
<div *ngIf="loading()">
<form
[formGroup]="formGroup"
[bitSubmit]="submit"
bit-dialog
[loading]="loading()"
[title]="'editPolicy' | i18n"
[subtitle]="policy.name | i18n"
>
<ng-container bitDialogContent>
@if (loading()) {
<div>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
@ -9,26 +16,20 @@
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div [hidden]="loading()">
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
<ng-template #policyForm></ng-template>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled()"
bitFormButton
type="submit"
>
{{ "save" | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
}
<div [hidden]="loading()">
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
<ng-template #policyForm></ng-template>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" [disabled]="saveDisabled()" bitFormButton type="submit">
{{ "save" | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-container>
</form>

View File

@ -197,4 +197,11 @@ export class PolicyEditDialogComponent implements AfterViewInit {
) => {
return dialogService.open<PolicyEditDialogResult>(PolicyEditDialogComponent, config);
};
static readonly openDrawer = (
dialogService: DialogService,
config: DialogConfig<PolicyEditDialogData>,
) => {
return dialogService.openDrawer<PolicyEditDialogResult>(PolicyEditDialogComponent, config);
};
}

View File

@ -1,47 +1,48 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
@let titleTemplate = policySteps()[currentStep()]?.titleContent?.();
<bit-dialog
[loading]="loading()"
[title]="titleTemplate ? undefined : ('editPolicy' | i18n)"
[subtitle]="titleTemplate ? undefined : (policy.name | i18n)"
>
<ng-container bitDialogTitle>
@if (titleTemplate) {
<ng-container [ngTemplateOutlet]="titleTemplate"></ng-container>
}
</ng-container>
<form
[formGroup]="formGroup"
[bitSubmit]="submit"
bit-dialog
[loading]="loading()"
[title]="policySteps()[currentStep()]?.titleContent?.() ? undefined : ('editPolicy' | i18n)"
[subtitle]="policySteps()[currentStep()]?.titleContent?.() ? undefined : (policy.name | i18n)"
>
<ng-container bitDialogTitle>
@let title = policySteps()[currentStep()]?.titleContent?.();
@if (title) {
<ng-container [ngTemplateOutlet]="title"></ng-container>
}
</ng-container>
<ng-container bitDialogContent>
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
<ng-container bitDialogContent>
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
@let bodyTemplate = policySteps()[currentStep()]?.bodyContent?.();
@if (bodyTemplate) {
<ng-container [ngTemplateOutlet]="bodyTemplate"></ng-container>
}
<!-- Always rendered so viewChild resolves before ngAfterViewInit runs. -->
<ng-template #policyForm></ng-template>
</ng-container>
@let bodyTemplate = policySteps()[currentStep()]?.bodyContent?.();
@if (bodyTemplate) {
<ng-container [ngTemplateOutlet]="bodyTemplate"></ng-container>
}
<!-- Always rendered so viewChild resolves before ngAfterViewInit runs. -->
<ng-template #policyForm></ng-template>
</ng-container>
<ng-container bitDialogFooter>
@let footerTemplate = policySteps()[currentStep()]?.footerContent?.();
@if (footerTemplate) {
<ng-container [ngTemplateOutlet]="footerTemplate"></ng-container>
} @else {
<button
bitButton
buttonType="primary"
[disabled]="loading() || saveDisabled()"
bitFormButton
type="submit"
>
{{ (currentStep() === policySteps().length - 1 ? "save" : "next") | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
}
</ng-container>
</bit-dialog>
<ng-container bitDialogFooter>
@let footerTemplate = policySteps()[currentStep()]?.footerContent?.();
@if (footerTemplate) {
<ng-container [ngTemplateOutlet]="footerTemplate"></ng-container>
} @else {
<button
bitButton
buttonType="primary"
[disabled]="loading() || saveDisabled()"
bitFormButton
type="submit"
>
{{ (currentStep() === policySteps().length - 1 ? "save" : "next") | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
}
</ng-container>
</form>

View File

@ -163,4 +163,14 @@ export class MultiStepPolicyEditDialogComponent
config,
);
};
static override readonly openDrawer = (
dialogService: DialogService,
config: DialogConfig<PolicyEditDialogData>,
) => {
return dialogService.openDrawer<PolicyEditDialogResult, PolicyEditDialogData>(
MultiStepPolicyEditDialogComponent,
config,
);
};
}

View File

@ -13,6 +13,7 @@ export enum FeatureFlag {
/* Admin Console Team */
AdminResetTwoFactor = "pm-15489-reset-two-factor-account-recovery",
GenerateInviteLink = "pm-32497-generate-invite-link",
PolicyDrawers = "pm-34804-policy-drawers",
/* Auth */
PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password",
@ -117,6 +118,7 @@ export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.AdminResetTwoFactor]: FALSE,
[FeatureFlag.GenerateInviteLink]: FALSE,
[FeatureFlag.PolicyDrawers]: FALSE,
/* Autofill */
[FeatureFlag.FillAssistTargetingRules]: FALSE,