diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 76507b1ca5f9..581f7451a624 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -214,6 +214,33 @@ "takeAQuickTourOfAccessIntelligence": { "message": "Take a quick tour of Access Intelligence and see exactly how to turn your org's data into action." }, + "yourEntireOrgsSecurityInOneView": { + "message": "Your entire org's security, in one view" + }, + "accessIntelligenceGivesYouSinglePlace": { + "message": "Access Intelligence gives you a single place to see every app, password, and member that's at risk." + }, + "youSetThePrioritiesWeSurfaceTheRisks": { + "message": "You set the priorities. We surface the risks." + }, + "youMarkWhichAppsAreMostCritical": { + "message": "You mark which apps are most critical, and Access Intelligence automatically identifies the at-risk passwords that need attention first." + }, + "trackImprovementsAcrossYourTeam": { + "message": "Track improvements across your team" + }, + "membersAreAutomaticallyNotified": { + "message": "Members are automatically notified of at-risk passwords in their Bitwarden extension. No chasing required." + }, + "importYourOrgDataToGetStarted": { + "message": "Import your org data to get started" + }, + "onceItHasTheVaultData": { + "message": "Once it has the vault data, Access Intelligence will have everything it needs to report on your org's password health." + }, + "accessIntelligenceWelcomeTour": { + "message": "Access Intelligence welcome tour" + }, "startTour": { "message": "Start tour" }, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.html new file mode 100644 index 000000000000..9aaf3d5e9278 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.html @@ -0,0 +1,116 @@ + +
+ + + +
+ +

{{ "yourEntireOrgsSecurityInOneView" | i18n }}

+

{{ "accessIntelligenceGivesYouSinglePlace" | i18n }}

+
+
+ + + +
+ +

{{ "youSetThePrioritiesWeSurfaceTheRisks" | i18n }}

+

{{ "youMarkWhichAppsAreMostCritical" | i18n }}

+
+
+ + + +
+ +

{{ "trackImprovementsAcrossYourTeam" | i18n }}

+

{{ "membersAreAutomaticallyNotified" | i18n }}

+
+
+ + + +
+ +

{{ "importYourOrgDataToGetStarted" | i18n }}

+

{{ "onceItHasTheVaultData" | i18n }}

+
+
+ + +
+ @if (isFirstSlide()) { + + } @else { + + } + + @if (isLastSlide()) { + + } @else { + + } +
+
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.spec.ts new file mode 100644 index 000000000000..bf6324de4500 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.spec.ts @@ -0,0 +1,117 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { of } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { ButtonModule, DialogModule, DialogRef, DIALOG_DATA } from "@bitwarden/components"; +import { VaultCarouselModule } from "@bitwarden/vault"; + +import { NewAdminWelcomeDialogComponent } from "./new-admin-welcome-dialog.component"; +import { OnboardingService } from "./services/onboarding.service"; + +const mockOrganizationId = "test-org-id" as OrganizationId; + +const mockDialogRef = { + close: jest.fn(), + afterClosed: jest.fn().mockReturnValue(of(undefined)), + closed: of(undefined), +} as unknown as DialogRef; + +const mockOnboardingService = { + setNewAdminWelcomeDialogAcknowledged: jest.fn().mockResolvedValue(undefined), +}; + +const mockRouter = { + navigate: jest.fn().mockResolvedValue(true), +}; + +describe("NewAdminWelcomeDialogComponent", () => { + let component: NewAdminWelcomeDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + jest.clearAllMocks(); + + await TestBed.configureTestingModule({ + imports: [NewAdminWelcomeDialogComponent, VaultCarouselModule, DialogModule, ButtonModule], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: DialogRef, useValue: mockDialogRef }, + { provide: OnboardingService, useValue: mockOnboardingService }, + { provide: Router, useValue: mockRouter }, + { provide: DIALOG_DATA, useValue: { organizationId: mockOrganizationId } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(NewAdminWelcomeDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("starts on the first slide", () => { + expect(component["currentSlide"]()).toBe(0); + expect(component["isFirstSlide"]()).toBe(true); + expect(component["isLastSlide"]()).toBe(false); + }); + + it("onSlideChange updates the currentSlide signal", () => { + component["onSlideChange"](2); + expect(component["currentSlide"]()).toBe(2); + }); + + it("isLastSlide is true when on slide 3 (index 3)", () => { + component["onSlideChange"](3); + expect(component["isLastSlide"]()).toBe(true); + expect(component["isFirstSlide"]()).toBe(false); + }); + + describe("onSkip", () => { + it("calls setNewAdminWelcomeDialogAcknowledged", async () => { + await component["onSkip"](); + expect(mockOnboardingService.setNewAdminWelcomeDialogAcknowledged).toHaveBeenCalledTimes(1); + }); + + it("closes the dialog", async () => { + await component["onSkip"](); + expect(mockDialogRef.close).toHaveBeenCalledTimes(1); + }); + }); + + describe("onImportData", () => { + it("calls setNewAdminWelcomeDialogAcknowledged", async () => { + await component["onImportData"](); + expect(mockOnboardingService.setNewAdminWelcomeDialogAcknowledged).toHaveBeenCalledTimes(1); + }); + + it("navigates to the org import page", async () => { + await component["onImportData"](); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ["/organizations", mockOrganizationId, "settings", "tools", "import"], + { queryParams: { returnTo: "access-intelligence" } }, + ); + }); + + it("closes the dialog", async () => { + await component["onImportData"](); + expect(mockDialogRef.close).toHaveBeenCalledTimes(1); + }); + + it("closes the dialog before navigating", async () => { + const callOrder: string[] = []; + (mockDialogRef.close as jest.Mock).mockImplementation(() => callOrder.push("close")); + mockRouter.navigate.mockImplementation(async () => { + callOrder.push("navigate"); + return true; + }); + + await component["onImportData"](); + + expect(callOrder).toEqual(["close", "navigate"]); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.stories.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.stories.ts new file mode 100644 index 000000000000..ecda5866575a --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.stories.ts @@ -0,0 +1,65 @@ +import { Router } from "@angular/router"; +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { + ButtonModule, + DialogModule, + DialogRef, + DIALOG_DATA, + TypographyModule, + I18nMockService, +} from "@bitwarden/components"; +import { VaultCarouselModule } from "@bitwarden/vault"; + +import { NewAdminWelcomeDialogComponent } from "./new-admin-welcome-dialog.component"; +import { OnboardingService } from "./services/onboarding.service"; + +const mockDialogRef = { close: async () => {} }; +const mockOnboardingService = { + setPostImportDialogAcknowledged: async () => {}, + setNewAdminWelcomeDialogAcknowledged: async () => {}, +}; +const mockOrganizationId = "story-org-id" as OrganizationId; + +const mockI18nService = new I18nMockService({ + accessIntelligenceWelcomeTour: "Welcome to Access Intelligence!", + yourEntireOrgsSecurityInOneView: "Your entire org's security in one view", + accessIntelligenceGivesYouSinglePlace: + "Access Intelligence gives you a single place to view and manage your organization's security posture, so you can spend less time on security administration and more time on strategic initiatives.", + youSetThePrioritiesWeSurfaceTheRisks: "You set the priorities, we surface the risks.", + youMarkWhichAppsAreMostCritical: + "You mark which apps are most critical to your org, and Access Intelligence surfaces the riskiest accounts and weakest links in those apps, so you can focus on what matters most.", + trackImprovementsAcrossYourTeam: "Track improvements across your team.", + membersAreAutomaticallyNotified: + "Members are automatically notified of security risks and can take action to resolve them, making it easier than ever to maintain a strong security posture across your organization.", + importYourOrgDataToGetStarted: "Import your org data to get started", + onceItHasTheVaultData: + "Once it has the vault data, Access Intelligence can start surfacing insights and recommendations to help you improve your organization's security.", + skip: "Skip", + back: "Back", + next: "Next", + importData: "Import Data", +}); + +export default { + title: "Access Intelligence/NewAdminWelcomeDialog", + component: NewAdminWelcomeDialogComponent, + decorators: [ + moduleMetadata({ + imports: [VaultCarouselModule, DialogModule, ButtonModule, TypographyModule], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: DialogRef, useValue: mockDialogRef }, + { provide: OnboardingService, useValue: mockOnboardingService }, + { provide: DIALOG_DATA, useValue: { organizationId: mockOrganizationId } }, + { provide: Router, useValue: { navigate: async () => {} } }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.ts new file mode 100644 index 000000000000..1f17ac0ecfbf --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/new-admin-welcome-dialog.component.ts @@ -0,0 +1,91 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + Injector, + runInInjectionContext, + signal, +} from "@angular/core"; +import { Router } from "@angular/router"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { + ButtonModule, + DialogModule, + DialogRef, + DialogService, + DIALOG_DATA, + TypographyModule, +} from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { VaultCarouselModule } from "@bitwarden/vault"; + +import { OnboardingService } from "./services/onboarding.service"; + +export type WelcomeCarouselDialogData = { + organizationId: OrganizationId; +}; + +const TOTAL_SLIDES = 4; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-new-admin-welcome-dialog", + imports: [ButtonModule, DialogModule, I18nPipe, TypographyModule, VaultCarouselModule], + templateUrl: "./new-admin-welcome-dialog.component.html", +}) +export class NewAdminWelcomeDialogComponent { + private readonly dialogRef = inject(DialogRef); + private readonly router = inject(Router); + private readonly onboardingService = inject(OnboardingService); + private readonly data = inject(DIALOG_DATA); + + protected readonly currentSlide = signal(0); + protected readonly isFirstSlide = computed(() => this.currentSlide() === 0); + protected readonly isLastSlide = computed(() => this.currentSlide() === TOTAL_SLIDES - 1); + + protected onSlideChange(index: number): void { + this.currentSlide.set(index); + } + + protected async onSkip(): Promise { + await this.onboardingService.setNewAdminWelcomeDialogAcknowledged(); + await this.dialogRef.close(); + } + + protected async onImportData(): Promise { + await this.onboardingService.setNewAdminWelcomeDialogAcknowledged(); + await this.dialogRef.close(); + await this.router.navigate( + ["/organizations", this.data.organizationId, "settings", "tools", "import"], + { queryParams: { returnTo: "access-intelligence" } }, + ); + } + + static async showDialog( + injector: Injector, + dialogService: DialogService, + organizationId: OrganizationId, + ): Promise | undefined> { + return runInInjectionContext(injector, async () => { + const logger = inject(LogService); + const onboardingService = inject(OnboardingService); + const acknowledged = await onboardingService.isNewAdminWelcomeDialogAcknowledged(); + if (acknowledged) { + logger.info( + "[Access Intelligence Onboarding] Welcome dialog already acknowledged, skipping dialog display.", + ); + return; + } + + const dialog = dialogService.open(NewAdminWelcomeDialogComponent, { + data: { organizationId } satisfies WelcomeCarouselDialogData, + width: "600px", + disableClose: true, + }); + return dialog; + }); + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.html similarity index 83% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.html index 3b2358440940..b89dbd390e1f 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.html @@ -1,7 +1,12 @@
- +

{{ "yourDataIsInLetsPutItToWork" | i18n }}

diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.spec.ts new file mode 100644 index 000000000000..a9c1f2b3c1a7 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { + ButtonModule, + DialogModule, + DialogRef, + DialogService, + TypographyModule, + DIALOG_DATA, +} from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +import { PostImportModalDialogComponent } from "./post-import-modal-dialog.component"; +import { OnboardingService } from "./services/onboarding.service"; + +const mockOrganizationId = "test-org-id" as OrganizationId; + +const mockDialogRef = { + close: jest.fn().mockResolvedValue(undefined), + afterClosed: jest.fn().mockReturnValue(of(undefined)), + closed: of(undefined), +} as unknown as DialogRef; + +const mockDialogService = { + open: jest.fn().mockReturnValue(mockDialogRef), +}; + +const mockOnboardingService = { + setPostImportDialogAcknowledged: jest.fn().mockResolvedValue(undefined), + isPostImportDialogAcknowledged: jest.fn().mockResolvedValue(false), +}; + +const mockLogger = { + error: jest.fn(), +}; + +describe("PostImportModalDialogComponent", () => { + let component: PostImportModalDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + jest.clearAllMocks(); + + await TestBed.configureTestingModule({ + imports: [PostImportModalDialogComponent, TypographyModule, ButtonModule, DialogModule], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: OnboardingService, useValue: mockOnboardingService }, + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DialogService, useValue: mockDialogService }, + { provide: DIALOG_DATA, useValue: { organizationId: mockOrganizationId } }, + { provide: LogService, useValue: mockLogger }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PostImportModalDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("onSkip", () => { + it("calls setPostImportDialogAcknowledged and closes the dialog", async () => { + await component["onSkip"](); + expect(mockOnboardingService.setPostImportDialogAcknowledged).toHaveBeenCalled(); + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.ts similarity index 53% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.ts index c24197789d57..4d8a28b3e27a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.ts @@ -6,6 +6,7 @@ import { runInInjectionContext, } from "@angular/core"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { ButtonModule, DialogModule, @@ -18,38 +19,48 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { OnboardingService } from "./services/onboarding.service"; +export type WelcomeModalDialogData = { + organizationId: OrganizationId; +}; + @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - selector: "app-welcome-modal-dialog", + selector: "app-post-import-modal-dialog", imports: [ButtonModule, TypographyModule, DialogModule, I18nPipe], - templateUrl: "./welcome-modal-dialog.component.html", + templateUrl: "./post-import-modal-dialog.component.html", }) -export class WelcomeModalDialogComponent { - private readonly dialogRef = inject(DialogRef); +export class PostImportModalDialogComponent { + private readonly dialogRef = inject(DialogRef); private readonly onboardingService = inject(OnboardingService); + private readonly logger = inject(LogService); - protected async onStartTour() { - // invoke the dialog here + protected async onStartTour(): Promise { await this.dialogRef.close(); } - protected async onSkip() { + protected async onSkip(): Promise { await this.onboardingService - .setWelcomeDialogAcknowledged() + .setPostImportDialogAcknowledged() .then(() => { return this.dialogRef.close(); }) - .catch(() => {}); + .catch((error: unknown) => { + this.logger.error( + "[Post Import Modal Dialog] Error acknowledging post-import dialog", + error, + ); + }); } - static async showWelcomeDialog( + static async showDialog( injector: Injector, dialogService: DialogService, - ): Promise | undefined> { + organizationId: OrganizationId, + ): Promise | undefined> { return runInInjectionContext(injector, async () => { const logger = inject(LogService); const onboardingService = inject(OnboardingService); - const acknowledged = await onboardingService.isWelcomeDialogAcknowledged(); + const acknowledged = await onboardingService.isPostImportDialogAcknowledged(); if (acknowledged) { logger.info( "[Access Intelligence Onboarding] Welcome dialog already acknowledged, skipping dialog display.", @@ -57,7 +68,8 @@ export class WelcomeModalDialogComponent { return; } - const dialog = dialogService.open(WelcomeModalDialogComponent, { + const dialog = dialogService.open(PostImportModalDialogComponent, { + data: { organizationId } satisfies WelcomeModalDialogData, width: "600px", disableClose: true, }); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.spec.ts new file mode 100644 index 000000000000..d5e3c4e44b40 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.spec.ts @@ -0,0 +1,99 @@ +import { TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { StateProvider } from "@bitwarden/state"; + +import { OnboardingService } from "./onboarding.service"; + +const mockAccount = { id: "test-user-id-123" }; + +describe("OnboardingService", () => { + let service: OnboardingService; + let mockStateProvider: { getUserState$: jest.Mock; setUserState: jest.Mock }; + + beforeEach(async () => { + mockStateProvider = { + getUserState$: jest.fn().mockReturnValue(of(null)), + setUserState: jest.fn().mockResolvedValue(undefined), + }; + + await TestBed.configureTestingModule({ + providers: [ + OnboardingService, + { provide: AccountService, useValue: { activeAccount$: of(mockAccount) } }, + { provide: StateProvider, useValue: mockStateProvider }, + ], + }); + + service = TestBed.inject(OnboardingService); + }); + + describe("isNewAdminWelcomeDialogAcknowledged", () => { + it("returns false when state is null", async () => { + mockStateProvider.getUserState$.mockReturnValue(of(null)); + const result = await service.isNewAdminWelcomeDialogAcknowledged(); + expect(result).toBe(false); + }); + + it("returns false when state is false", async () => { + mockStateProvider.getUserState$.mockReturnValue(of(false)); + const result = await service.isNewAdminWelcomeDialogAcknowledged(); + expect(result).toBe(false); + }); + + it("returns true when state is true", async () => { + mockStateProvider.getUserState$.mockReturnValue(of(true)); + const result = await service.isNewAdminWelcomeDialogAcknowledged(); + expect(result).toBe(true); + }); + + it("returns false when there is no active account", async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + providers: [ + OnboardingService, + { provide: AccountService, useValue: { activeAccount$: of(null) } }, + { provide: StateProvider, useValue: mockStateProvider }, + ], + }); + const service = TestBed.inject(OnboardingService); + const result = await service.isNewAdminWelcomeDialogAcknowledged(); + expect(result).toBe(false); + }); + }); + + describe("setNewAdminWelcomeDialogAcknowledged", () => { + it("calls setUserState with true by default", async () => { + await service.setNewAdminWelcomeDialogAcknowledged(); + expect(mockStateProvider.setUserState).toHaveBeenCalledWith( + expect.objectContaining({ key: "accessIntelligenceNewAdminWelcomeAcknowledged" }), + true, + mockAccount.id, + ); + }); + + it("calls setUserState with the provided value", async () => { + await service.setNewAdminWelcomeDialogAcknowledged(false); + expect(mockStateProvider.setUserState).toHaveBeenCalledWith( + expect.objectContaining({ key: "accessIntelligenceNewAdminWelcomeAcknowledged" }), + false, + mockAccount.id, + ); + }); + + it("does not call setUserState when there is no active account", async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + providers: [ + OnboardingService, + { provide: AccountService, useValue: { activeAccount$: of(null) } }, + { provide: StateProvider, useValue: mockStateProvider }, + ], + }); + const onboardingSvc = TestBed.inject(OnboardingService); + await onboardingSvc.setNewAdminWelcomeDialogAcknowledged(); + expect(mockStateProvider.setUserState).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.ts index 9675c338f78e..51fc499dd553 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.ts @@ -8,12 +8,21 @@ import { UserKeyDefinition, } from "@bitwarden/state"; -const ACCESS_INTELLIGENCE_WELCOME_DIALOG_ACKNOWLEDGED_KEY = new UserKeyDefinition( +const ACCESS_INTELLIGENCE_POST_IMPORT_DIALOG_ACKNOWLEDGED_KEY = new UserKeyDefinition( ACCESS_INTELLIGENCE_WELCOME_DIALOG_DISK, - "accessIntelligenceWelcomeDialogCompleted", + "accessIntelligencePostImportDialogCompleted", { deserializer: (value) => value, - clearOn: [], + clearOn: [], // Post-import dialog acknowledged state should persist across lock/logout so the dialog is not reshown + }, +); + +const ACCESS_INTELLIGENCE_NEW_ADMIN_WELCOME_ACKNOWLEDGED_KEY = new UserKeyDefinition( + ACCESS_INTELLIGENCE_WELCOME_DIALOG_DISK, + "accessIntelligenceNewAdminWelcomeAcknowledged", + { + deserializer: (value) => value, + clearOn: [], // New admin welcome acknowledged state should persist across lock/logout so the tour is not reshown }, ); @@ -22,29 +31,37 @@ export class OnboardingService { private accountService = inject(AccountService); private stateProvider = inject(StateProvider); - async isWelcomeDialogAcknowledged(): Promise { + async isPostImportDialogAcknowledged(): Promise { + return this.isAcknowledged(ACCESS_INTELLIGENCE_POST_IMPORT_DIALOG_ACKNOWLEDGED_KEY); + } + + async setPostImportDialogAcknowledged(value = true) { + await this.setAcknowledged(ACCESS_INTELLIGENCE_POST_IMPORT_DIALOG_ACKNOWLEDGED_KEY, value); + } + + async isNewAdminWelcomeDialogAcknowledged(): Promise { + return this.isAcknowledged(ACCESS_INTELLIGENCE_NEW_ADMIN_WELCOME_ACKNOWLEDGED_KEY); + } + + async setNewAdminWelcomeDialogAcknowledged(value = true) { + await this.setAcknowledged(ACCESS_INTELLIGENCE_NEW_ADMIN_WELCOME_ACKNOWLEDGED_KEY, value); + } + + private async isAcknowledged(key: UserKeyDefinition): Promise { const account = await firstValueFrom(this.accountService.activeAccount$); if (!account) { return false; } - const acknowledged = await firstValueFrom( - this.stateProvider - .getUserState$(ACCESS_INTELLIGENCE_WELCOME_DIALOG_ACKNOWLEDGED_KEY, account.id) - .pipe(map((v) => v ?? false)), + return await firstValueFrom( + this.stateProvider.getUserState$(key, account.id).pipe(map((v) => v ?? false)), ); - - return acknowledged; } - async setWelcomeDialogAcknowledged(value = true) { + private async setAcknowledged(key: UserKeyDefinition, value = true) { const account = await firstValueFrom(this.accountService.activeAccount$); if (account) { - await this.stateProvider.setUserState( - ACCESS_INTELLIGENCE_WELCOME_DIALOG_ACKNOWLEDGED_KEY, - value, - account.id, - ); + await this.stateProvider.setUserState(key, value, account.id); } } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.spec.ts deleted file mode 100644 index 8de15435558d..000000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { of } from "rxjs"; - -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ButtonModule, DialogModule, DialogRef, TypographyModule } from "@bitwarden/components"; - -import { OnboardingService } from "./services/onboarding.service"; -import { WelcomeModalDialogComponent } from "./welcome-modal-dialog.component"; - -const mockDialogRef = { - close: jest.fn(), - afterClosed: jest.fn().mockReturnValue(of(undefined)), - closed: of(undefined), -} as unknown as import("@bitwarden/components").DialogRef; - -describe("WelcomeModalDialogComponent", () => { - let component: WelcomeModalDialogComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - const mockI18nService = { - t: jest.fn((key: string) => key), - }; - const mockOnboardingService = { - setWelcomeDialogAcknowledged: jest.fn().mockResolvedValue(undefined), - isWelcomeDialogAcknowledged: jest.fn().mockResolvedValue(false), - }; - - await TestBed.configureTestingModule({ - imports: [WelcomeModalDialogComponent, TypographyModule, ButtonModule, DialogModule], - providers: [ - { provide: I18nService, useValue: mockI18nService }, - { provide: OnboardingService, useValue: mockOnboardingService }, - { provide: DialogRef, useValue: mockDialogRef }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(WelcomeModalDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index fc2eb27c7500..45e4a868808b 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -109,7 +109,8 @@ @if (isDevMode || adoptionUxImprovementsEnabled) { } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 320f5ac64e7a..5abfd2455bf2 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -7,15 +7,16 @@ import { DestroyRef, OnDestroy, OnInit, + afterNextRender, inject, signal, ChangeDetectionStrategy, isDevMode, Injector, } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { concat, EMPTY, firstValueFrom, of } from "rxjs"; +import { combineLatest, concat, EMPTY, firstValueFrom, of } from "rxjs"; import { concatMap, delay, @@ -24,6 +25,7 @@ import { map, skip, switchMap, + take, tap, } from "rxjs/operators"; @@ -59,7 +61,8 @@ import { ApplicationsComponent } from "./all-applications/applications.component import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; import { EmptyStateCardComponent } from "./empty-state-card.component"; import { RiskInsightsTabType } from "./models/risk-insights.models"; -import { WelcomeModalDialogComponent } from "./onboarding/welcome-modal-dialog.component"; +import { NewAdminWelcomeDialogComponent } from "./onboarding/new-admin-welcome-dialog.component"; +import { PostImportModalDialogComponent } from "./onboarding/post-import-modal-dialog.component"; import { DevMenuComponent } from "./shared/dev-menu.component"; import { PageLoadingComponent } from "./shared/page-loading.component"; import { ReportLoadingComponent } from "./shared/report-loading.component"; @@ -99,6 +102,7 @@ type ProgressStep = ReportProgress | null; }) export class RiskInsightsComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); + private readonly mustBeginPostImportTour = signal(false); protected ReportStatusEnum = ReportStatus; protected milestone11Enabled: boolean = false; protected adoptionUxImprovementsEnabled: boolean = false; @@ -198,17 +202,38 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { this.dataLastUpdated = report?.creationDate ?? null; }); - // Show error toast when report generation or save fails - this.dataService.reportStatus$ + // Show error toast or begin post-import tour based on report status and loading state. + // combineLatest naturally waits for both conditions: status must be Error/Complete, + // and the progress loader must be done (currentProgressStep === null) before the tour opens. + // distinctUntilChanged on status prevents duplicate firings if currentProgressStep keeps + // changing while status stays the same (e.g. Error). + combineLatest([ + this.dataService.reportStatus$, + toObservable(this.currentProgressStep, { injector: this.injector }), + ]) .pipe( - filter((status) => status === ReportStatus.Error), + filter( + ([reportStatus, step]) => + reportStatus === ReportStatus.Error || + (reportStatus === ReportStatus.Complete && step === null), + ), + distinctUntilChanged( + ([prevReportStatus], [currentReportStatus]) => prevReportStatus === currentReportStatus, + ), takeUntilDestroyed(this.destroyRef), ) - .subscribe(() => { - this.toastService.showToast({ - message: this.i18nService.t("reportGenerationFailed"), - variant: "error", - }); + .subscribe(([status]) => { + if (status === ReportStatus.Error) { + this.toastService.showToast({ + message: this.i18nService.t("reportGenerationFailed"), + variant: "error", + }); + } else if (this.mustBeginPostImportTour()) { + this.mustBeginPostImportTour.set(false); + + // open the dialog only after the rendering of the report is complete + afterNextRender(() => void this.beginPostImportTour(), { injector: this.injector }); + } }); // Subscribe to drawer state changes @@ -277,6 +302,19 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { this.currentProgressStep.set(step); }); + combineLatest([this.dataService.hasReportData$, this.dataService.hasCiphers$]) + .pipe( + takeUntilDestroyed(this.destroyRef), + filter(([hasReportData, hasCiphers]) => hasReportData !== null && hasCiphers !== null), + filter(([hasReportData, hasCiphers]) => !hasReportData && !hasCiphers), + take(1), + ) + .subscribe(() => { + void this.beginNewAdminWelcomeTour().catch((error: unknown) => { + this.logService.error("Failed to launch onboarding welcome", error); + }); + }); + if (this.invokedFrom()?.source && this.invokedFrom()?.status) { await this.handleReturnParams(this.invokedFrom()?.source, this.invokedFrom()?.status); } @@ -390,10 +428,9 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { ): Promise { if (source === "import" && status === "success") { this.generateReport(); - await this.beginOnboardingTour(); + this.mustBeginPostImportTour.set(true); } - await this.beginOnboardingTour(); this.clearQueryParams(this.router, this.route, ["source", "status"]); } @@ -407,9 +444,23 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { }); } - protected async beginOnboardingTour(): Promise { + protected async beginPostImportTour(): Promise { + if (this.adoptionUxImprovementsEnabled) { + await PostImportModalDialogComponent.showDialog( + this.injector, + this.dialogService, + this.organizationId, + ); + } + } + + protected async beginNewAdminWelcomeTour(): Promise { if (this.adoptionUxImprovementsEnabled) { - await WelcomeModalDialogComponent.showWelcomeDialog(this.injector, this.dialogService); + await NewAdminWelcomeDialogComponent.showDialog( + this.injector, + this.dialogService, + this.organizationId, + ); } } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.html index c6fbdef99c58..9f9cbddb15b8 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.html @@ -16,31 +16,60 @@ Import Data from Access Intelligence
+
+
+
+ +
+
+ +
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.ts index 89c182d92503..5d5a83fd5928 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.ts @@ -30,15 +30,21 @@ export class DevMenuComponent implements OnInit { private readonly elementRef = inject(ElementRef); private readonly onboardingService = inject(OnboardingService); private readonly logger = inject(LogService); - protected readonly welcomeDialogAcked = signal(false); + protected readonly postImportDialogAcked = signal(false); + protected readonly newAdminWelcomeDialogAcked = signal(false); - readonly beginTour = output(); + readonly beginNewAdminWelcomeTour = output(); + readonly beginPostImportTour = output(); readonly importData = output(); protected readonly isOpen = signal(false); async ngOnInit(): Promise { - const isAck = await this.onboardingService.isWelcomeDialogAcknowledged(); - this.welcomeDialogAcked.set(isAck); + const isPostImportDialogAcked = await this.onboardingService.isPostImportDialogAcknowledged(); + this.postImportDialogAcked.set(isPostImportDialogAcked); + + const newAdminWelcomeDialogAcked = + await this.onboardingService.isNewAdminWelcomeDialogAcknowledged(); + this.newAdminWelcomeDialogAcked.set(newAdminWelcomeDialogAcked); } @HostListener("document:keydown", ["$event"]) @@ -63,9 +69,14 @@ export class DevMenuComponent implements OnInit { } } - protected onBeginTour(): void { + protected onBeginPostImportTour(): void { + this.isOpen.set(false); + this.beginPostImportTour.emit(); + } + + protected onBeginNewAdminWelcomeTour(): void { this.isOpen.set(false); - this.beginTour.emit(); + this.beginNewAdminWelcomeTour.emit(); } protected onImportData(): void { @@ -73,27 +84,55 @@ export class DevMenuComponent implements OnInit { this.importData.emit(); } - protected async onResetWelcomeDialogAck(): Promise { + protected async onResetPostImportDialogAck(): Promise { try { - await this.onboardingService.setWelcomeDialogAcknowledged(false); - this.welcomeDialogAcked.set(false); - this.logger.info("Reset Access Intelligence welcome dialog acknowledged state."); + await this.onboardingService.setPostImportDialogAcknowledged(false); + this.postImportDialogAcked.set(false); + this.logger.info("Reset Access Intelligence post import dialog acknowledged state."); } catch (error) { this.logger.error( - "Failed to reset Access Intelligence welcome dialog acknowledged state.", + "Failed to reset Access Intelligence post import dialog acknowledged state.", error, ); } } - protected async onShowWelcomeDialogAckState(): Promise { + protected async onShowPostImportDialogAckState(): Promise { try { - const isAck = await this.onboardingService.isWelcomeDialogAcknowledged(); - this.welcomeDialogAcked.set(isAck); - this.logger.info(`Access Intelligence welcome dialog acknowledged state: ${isAck}.`); + const isAck = await this.onboardingService.isPostImportDialogAcknowledged(); + this.postImportDialogAcked.set(isAck); + this.logger.info(`Access Intelligence post import dialog acknowledged state: ${isAck}.`); + } catch (error) { + this.logger.error( + "Failed to get Access Intelligence post import dialog acknowledged state.", + error, + ); + } + } + + protected async onResetNewAdminWelcomeDialogAck(): Promise { + try { + await this.onboardingService.setNewAdminWelcomeDialogAcknowledged(false); + this.newAdminWelcomeDialogAcked.set(false); + this.logger.info("Reset Access Intelligence new admin welcome dialog acknowledged state."); + } catch (error) { + this.logger.error( + "Failed to reset Access Intelligence new admin welcome dialog acknowledged state.", + error, + ); + } + } + + protected async onShowNewAdminWelcomeDialogAckState(): Promise { + try { + const isAck = await this.onboardingService.isNewAdminWelcomeDialogAcknowledged(); + this.newAdminWelcomeDialogAcked.set(isAck); + this.logger.info( + `Access Intelligence new admin welcome dialog acknowledged state: ${isAck}.`, + ); } catch (error) { this.logger.error( - "Failed to get Access Intelligence welcome dialog acknowledged state.", + "Failed to get Access Intelligence new admin welcome dialog acknowledged state.", error, ); } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.html index c6e56c33efd2..d7c517b1d2cd 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.html @@ -107,7 +107,8 @@ @if (isDevMode() || adoptionUxImprovementsEnabled()) { } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.ts index 6b1c43e34298..45fa8cf9433f 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.ts @@ -11,11 +11,13 @@ import { ChangeDetectionStrategy, Injector, isDevMode, + effect, + afterNextRender, } from "@angular/core"; import { toObservable, toSignal, takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { combineLatest, concat, distinctUntilChanged, filter, map, of, switchMap } from "rxjs"; -import { concatMap, delay, finalize, skip } from "rxjs/operators"; +import { concatMap, delay, finalize, skip, take } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -47,7 +49,8 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { EmptyStateCardComponent } from "../../empty-state-card.component"; import { RiskInsightsTabType } from "../../models/risk-insights.models"; -import { WelcomeModalDialogComponent } from "../../onboarding/welcome-modal-dialog.component"; +import { NewAdminWelcomeDialogComponent } from "../../onboarding/new-admin-welcome-dialog.component"; +import { PostImportModalDialogComponent } from "../../onboarding/post-import-modal-dialog.component"; import { DevMenuComponent } from "../../shared/dev-menu.component"; import { PageLoadingComponent } from "../../shared/page-loading.component"; import { ReportLoadingComponent } from "../../shared/report-loading.component"; @@ -101,6 +104,7 @@ type ProgressStep = ReportProgress | null; }) export class AccessIntelligencePageComponent implements OnInit, OnDestroy { private readonly destroyRef = inject(DestroyRef); + private readonly mustBeginPostImportTour = signal(false); protected readonly tabIndex = signal(RiskInsightsTabType.AllActivity); @@ -213,6 +217,18 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy { .subscribe((step) => { this.currentProgressStep.set(step); }); + + effect(() => { + // determine if we need to begin the post import tour + // to be launched when report generation is complete + // and mustBeginPostImportTour is true (set when user is navigated from import page after successful import + if (this.currentProgressStep() === null && this.mustBeginPostImportTour()) { + this.mustBeginPostImportTour.set(false); + + // open the dialog only after the rendering of the report is complete + afterNextRender(() => void this.beginPostImportTour(), { injector: this.injector }); + } + }); } async ngOnInit() { @@ -236,6 +252,25 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy { // Close any open dialogs (happens when navigating between orgs) void this.currentDialogRef()?.close(); + // determine if we need to launch the new admin welcome tour + // launch when there are no reports and no ciphers. + combineLatest([ + toObservable(this.hasReportData, { injector: this.injector }), + toObservable(this.hasCiphers, { injector: this.injector }), + toObservable(this.initializing, { injector: this.injector }), + ]) + .pipe( + takeUntilDestroyed(this.destroyRef), + filter(([_, __, initializing]) => !initializing), // Wait until initialization is complete + filter(([hasReportData, hasCiphers]) => !hasReportData && !hasCiphers), + take(1), + ) + .subscribe(() => { + void this.beginNewAdminWelcomeTour().catch((error: unknown) => { + this.logService.error("Failed to launch onboarding welcome", error); + }); + }); + if (this.invokedFrom()?.source && this.invokedFrom()?.status) { await this.handleReturnParams(this.invokedFrom()?.source, this.invokedFrom()?.status); } @@ -430,7 +465,7 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy { ): Promise { if (source === "import" && status === "success") { this.generateReport(); - await this.beginOnboardingTour(); + this.mustBeginPostImportTour.set(true); } this.clearQueryParams(this.router, this.route, ["source", "status"]); @@ -446,9 +481,24 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy { }); } - protected async beginOnboardingTour(): Promise { + protected async beginPostImportTour(): Promise { + if (this.adoptionUxImprovementsEnabled()) { + this.mustBeginPostImportTour.set(false); + await PostImportModalDialogComponent.showDialog( + this.injector, + this.dialogService, + this.organizationId(), + ); + } + } + + protected async beginNewAdminWelcomeTour(): Promise { if (this.adoptionUxImprovementsEnabled()) { - await WelcomeModalDialogComponent.showWelcomeDialog(this.injector, this.dialogService); + await NewAdminWelcomeDialogComponent.showDialog( + this.injector, + this.dialogService, + this.organizationId(), + ); } } } diff --git a/libs/vault/src/components/carousel/carousel.component.html b/libs/vault/src/components/carousel/carousel.component.html index 81689e9cb00b..25a82e6a2c85 100644 --- a/libs/vault/src/components/carousel/carousel.component.html +++ b/libs/vault/src/components/carousel/carousel.component.html @@ -7,15 +7,19 @@ >
- + + @if (!hideArrows()) { + + } +
}
- + + @if (!hideArrows()) { + + } +
+ @if (minHeight === null) {
diff --git a/libs/vault/src/components/carousel/carousel.component.spec.ts b/libs/vault/src/components/carousel/carousel.component.spec.ts index eb9480398e9b..7f058d9ebb2e 100644 --- a/libs/vault/src/components/carousel/carousel.component.spec.ts +++ b/libs/vault/src/components/carousel/carousel.component.spec.ts @@ -100,3 +100,63 @@ describe("VaultCarouselComponent", () => { expect(component.slideChange.emit).toHaveBeenCalledWith(0); }); }); + +@Component({ + selector: "app-test-carousel-hide-arrows", + imports: [VaultCarouselComponent, VaultCarouselSlideComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +

Content 1

+

Content 2

+
+ +
+
+ `, +}) +class TestCarouselHideArrowsComponent {} + +describe("VaultCarouselComponent with hideArrows", () => { + let fixture: ComponentFixture; + let carouselComponent: VaultCarouselComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VaultCarouselComponent, VaultCarouselSlideComponent], + providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], + }).compileComponents(); + + fixture = TestBed.createComponent(TestCarouselHideArrowsComponent); + fixture.detectChanges(); + carouselComponent = fixture.debugElement.query( + By.directive(VaultCarouselComponent), + ).componentInstance; + }); + + it("hides navigation arrow buttons when hideArrows is true", () => { + const iconButtons = fixture.debugElement.queryAll(By.css("[bitIconButton]")); + expect(iconButtons.length).toBe(0); + }); + + it("renders carouselActions slot content", () => { + const actionBtn = fixture.debugElement.query(By.css("[data-testid='action-btn']")); + expect(actionBtn).not.toBeNull(); + }); + + it("nextSlide advances to the next slide", () => { + const slideChangeSpy = jest.spyOn(carouselComponent.slideChange, "emit"); + carouselComponent.nextSlide(); + fixture.detectChanges(); + expect(slideChangeSpy).toHaveBeenCalledWith(1); + }); + + it("prevSlide goes back to the previous slide after advancing", () => { + carouselComponent.nextSlide(); + fixture.detectChanges(); + const slideChangeSpy = jest.spyOn(carouselComponent.slideChange, "emit"); + carouselComponent.prevSlide(); + fixture.detectChanges(); + expect(slideChangeSpy).toHaveBeenCalledWith(0); + }); +}); diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts index c622f2e5d855..9b756404bdcd 100644 --- a/libs/vault/src/components/carousel/carousel.component.ts +++ b/libs/vault/src/components/carousel/carousel.component.ts @@ -10,6 +10,7 @@ import { ElementRef, EventEmitter, inject, + input, Input, NgZone, Output, @@ -54,6 +55,12 @@ export class VaultCarouselComponent implements AfterViewInit { // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) label = ""; + /** + * When true, hides the previous/next arrow navigation buttons. + * Use with the `carouselActions` content slot to provide custom navigation controls. + */ + protected readonly hideArrows = input(false); + /** * Emits the index of the newly selected slide. */ @@ -118,13 +125,13 @@ export class VaultCarouselComponent implements AfterViewInit { this.slideChange.emit(index); } - protected nextSlide() { + nextSlide() { if (this.selectedIndex < this.slides.length - 1) { this.selectSlide(this.selectedIndex + 1); } } - protected prevSlide() { + prevSlide() { if (this.selectedIndex > 0) { this.selectSlide(this.selectedIndex - 1); } diff --git a/libs/vault/src/components/carousel/carousel.stories.ts b/libs/vault/src/components/carousel/carousel.stories.ts index 1e393779a6a1..a839502fe6d4 100644 --- a/libs/vault/src/components/carousel/carousel.stories.ts +++ b/libs/vault/src/components/carousel/carousel.stories.ts @@ -76,3 +76,29 @@ export const KeyboardNavigation: Story = { `, }), }; + +export const HideArrowsWithActions: Story = { + render: (args: any) => ({ + props: args, + template: ` + + +
+

Slide Without Arrows

+

Dots only — no navigation arrows

+
+
+ +
+

Second Slide

+

Buttons projected below the dots via carouselActions slot

+
+
+
+ + +
+
+ `, + }), +};