From 581f69256d0dc407bee8dc7e4033e12369c44bc1 Mon Sep 17 00:00:00 2001 From: Will Martin Date: Tue, 21 Feb 2023 11:25:41 -0500 Subject: [PATCH] [SM-453] user onboarding component (#4707) * wip onboarding component * fix button type * remove dismiss button * add completion logic * update styles; add download cli section; add click logic; add loading spinner * update i18n * update icons; rearrange items; fix import item logic * add complete i18n * fix reactivity * move visibility logic into presentational component * add button type * apply code reviews * add loading spinner to page * onboarding dismissal should persist when switching orgs * add workaround for inconsistent icon size * fix full storybook * apply code review; update stories --- apps/web/src/locales/en/messages.json | 35 ++++++ .../overview/onboarding-task.component.html | 21 ++++ .../overview/onboarding-task.component.ts | 22 ++++ .../overview/onboarding.component.html | 41 +++++++ .../overview/onboarding.component.ts | 39 ++++++ .../overview/onboarding.module.ts | 14 +++ .../overview/onboarding.stories.ts | 92 ++++++++++++++ .../overview/overview.component.html | 93 +++++++++----- .../overview/overview.component.ts | 115 ++++++++++++------ .../overview/overview.module.ts | 3 +- libs/components/src/index.ts | 1 + 11 files changed, 408 insertions(+), 68 deletions(-) create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding-task.component.html create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding-task.component.ts create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.component.html create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.component.ts create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.module.ts create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.stories.ts diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index b74bebe161..06025f869d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6420,6 +6420,41 @@ "errorReadingImportFile": { "message": "An error occurred when trying to read the import file" }, + "createSecret": { + "message": "Create a secret" + }, + "createProject": { + "message": "Create a project" + }, + "createServiceAccount": { + "message": "Create a service account" + }, + "downloadThe": { + "message": "Download the", + "description": "Link to a downloadable resource. This will be used as part of a larger phrase. Example: Download the Secrets Manager CLI" + }, + "smCLI": { + "message": "Secrets Manager CLI" + }, + "importSecrets": { + "message": "Import secrets" + }, + "getStarted": { + "message": "Get started" + }, + "complete": { + "message": "$COMPLETED$/$TOTAL$ Complete", + "placeholders": { + "COMPLETED": { + "content": "$1", + "example": "1" + }, + "TOTAL": { + "content": "$2", + "example": "4" + } + } + }, "restoreSecret": { "message": "Restore secret" }, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding-task.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding-task.component.html new file mode 100644 index 0000000000..5904be7487 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding-task.component.html @@ -0,0 +1,21 @@ + + {{ title }} + + +
  • + + + + +
    + +
    +
  • diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding-task.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding-task.component.ts new file mode 100644 index 0000000000..cfd6d77dec --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding-task.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from "@angular/core"; + +@Component({ + selector: "sm-onboarding-task", + templateUrl: "./onboarding-task.component.html", + host: { + class: "tw-max-w-max", + }, +}) +export class OnboardingTaskComponent { + @Input() + completed = false; + + @Input() + icon = "bwi-info-circle"; + + @Input() + title: string; + + @Input() + route: string | any[]; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.component.html new file mode 100644 index 0000000000..5aa57b087d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.component.html @@ -0,0 +1,41 @@ +
    + +
    + +
    {{ title }}
    + + + {{ "complete" | i18n: amountCompleted:tasks.length }} + + +
    +
    + +
    + +
    +
    + + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.component.ts new file mode 100644 index 0000000000..06e5761b3d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.component.ts @@ -0,0 +1,39 @@ +import { AfterContentInit, Component, ContentChildren, Input, QueryList } from "@angular/core"; + +import { OnboardingTaskComponent } from "./onboarding-task.component"; + +@Component({ + selector: "sm-onboarding", + templateUrl: "./onboarding.component.html", +}) +export class OnboardingComponent implements AfterContentInit { + @ContentChildren(OnboardingTaskComponent) tasks: QueryList; + @Input() title: string; + + protected open = true; + protected visible = false; + + ngAfterContentInit() { + this.visible = !this.isComplete; + } + + protected get amountCompleted(): number { + return this.tasks.filter((task) => task.completed).length; + } + + protected get barWidth(): number { + return this.tasks.length === 0 ? 0 : (this.amountCompleted / this.tasks.length) * 100; + } + + protected get isComplete(): boolean { + return this.tasks.length > 0 && this.tasks.length === this.amountCompleted; + } + + protected toggle() { + this.open = !this.open; + } + + protected dismiss() { + this.visible = false; + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.module.ts new file mode 100644 index 0000000000..867475165e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from "@angular/core"; + +import { ProgressModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { OnboardingTaskComponent } from "./onboarding-task.component"; +import { OnboardingComponent } from "./onboarding.component"; + +@NgModule({ + imports: [SharedModule, ProgressModule], + exports: [OnboardingComponent, OnboardingTaskComponent], + declarations: [OnboardingComponent, OnboardingTaskComponent], +}) +export class OnboardingModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.stories.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.stories.ts new file mode 100644 index 0000000000..f8d156c3cb --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.stories.ts @@ -0,0 +1,92 @@ +import { RouterModule } from "@angular/router"; +import { Meta, Story, moduleMetadata } from "@storybook/angular"; +import { delay, of, startWith } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { LinkModule, IconModule, ProgressModule } from "@bitwarden/components"; +import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/app/tests/preloaded-english-i18n.module"; + +import { OnboardingTaskComponent } from "./onboarding-task.component"; +import { OnboardingComponent } from "./onboarding.component"; + +export default { + title: "Web/Onboarding", + component: OnboardingComponent, + decorators: [ + moduleMetadata({ + imports: [ + JslibModule, + RouterModule.forRoot( + [ + { + path: "", + component: OnboardingComponent, + }, + ], + { useHash: true } + ), + LinkModule, + IconModule, + ProgressModule, + PreloadedEnglishI18nModule, + ], + declarations: [OnboardingTaskComponent], + }), + ], +} as Meta; + +const Template: Story = (args) => ({ + props: { + createServiceAccount: false, + importSecrets$: of(false), + createSecret: false, + createProject: false, + ...args, + }, + template: ` + + + + {{ "downloadThe" | i18n }} {{ "smCLI" | i18n }} + + + + + + + `, +}); + +export const Empty = Template.bind({}); + +export const Partial = Template.bind({}); +Partial.args = { + ...Template.args, + createServiceAccount: true, + createProject: true, +}; + +export const Full = Template.bind({}); +Full.args = { + ...Template.args, + createServiceAccount: true, + createProject: true, + createSecret: true, + importSecrets$: of(true).pipe(delay(0), startWith(false)), +}; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html index aa9c243622..2182c9ca44 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html @@ -1,37 +1,68 @@ -
    - -

    {{ "projects" | i18n }}

    - -
    - {{ "showingPortionOfTotal" | i18n: view.latestProjects.length:view.allProjects.length }} - {{ "viewAll" | i18n }} -
    -
    - -

    {{ "secrets" | i18n }}

    - -
    - {{ "showingPortionOfTotal" | i18n: view.latestSecrets.length:view.allSecrets.length }} - {{ "viewAll" | i18n }} -
    -
    + +
    + + + + {{ "downloadThe" | i18n }} {{ "smCLI" | i18n }} + + + + + + + +
    + +

    {{ "projects" | i18n }}

    + +
    + {{ "showingPortionOfTotal" | i18n: view.latestProjects.length:view.allProjects.length }} + {{ "viewAll" | i18n }} +
    +
    + +

    {{ "secrets" | i18n }}

    + +
    + {{ "showingPortionOfTotal" | i18n: view.latestSecrets.length:view.allSecrets.length }} + {{ "viewAll" | i18n }} +
    +
    +
    diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index 97cce8d2b8..986e56bfab 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -1,14 +1,14 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { - combineLatest, - combineLatestWith, map, Observable, - startWith, - Subject, switchMap, + Subject, takeUntil, + combineLatest, + startWith, + distinct, } from "rxjs"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; @@ -21,7 +21,6 @@ import { ProjectDeleteOperation, } from "../projects/dialog/project-delete-dialog.component"; import { - OperationType, ProjectDialogComponent, ProjectOperation, } from "../projects/dialog/project-dialog.component"; @@ -30,41 +29,68 @@ import { SecretDeleteDialogComponent, SecretDeleteOperation, } from "../secrets/dialog/secret-delete.component"; -import { SecretDialogComponent, SecretOperation } from "../secrets/dialog/secret-dialog.component"; +import { + OperationType, + SecretDialogComponent, + SecretOperation, +} from "../secrets/dialog/secret-dialog.component"; import { SecretService } from "../secrets/secret.service"; +import { + ServiceAccountDialogComponent, + ServiceAccountOperation, +} from "../service-accounts/dialog/service-account-dialog.component"; +import { ServiceAccountService } from "../service-accounts/service-account.service"; + +type Tasks = { + importSecrets: boolean; + createSecret: boolean; + createProject: boolean; + createServiceAccount: boolean; +}; @Component({ selector: "sm-overview", templateUrl: "./overview.component.html", }) export class OverviewComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - /** - * Number of items to show in tables - */ + private destroy$: Subject = new Subject(); private tableSize = 10; private organizationId: string; - protected organizationName: string; + protected view$: Observable<{ allProjects: ProjectListView[]; allSecrets: SecretListView[]; latestProjects: ProjectListView[]; latestSecrets: SecretListView[]; + tasks: Tasks; }>; constructor( private route: ActivatedRoute, + private router: Router, private projectService: ProjectService, + private secretService: SecretService, + private serviceAccountService: ServiceAccountService, private dialogService: DialogService, - private organizationService: OrganizationService, - private secretService: SecretService - ) {} + private organizationService: OrganizationService + ) { + /** + * We want to remount the `sm-onboarding` component on route change. + * The component only toggles its visibility on init and on user dismissal. + */ + this.router.routeReuseStrategy.shouldReuseRoute = () => false; + } ngOnInit() { - this.route.params + const orgId$ = this.route.params.pipe( + map((p) => p.organizationId), + distinct() + ); + + orgId$ .pipe( - map((params) => this.organizationService.get(params.organizationId)), + map((orgId) => this.organizationService.get(orgId)), takeUntil(this.destroy$) ) .subscribe((org) => { @@ -72,25 +98,33 @@ export class OverviewComponent implements OnInit, OnDestroy { this.organizationName = org.name; }); - const projects$ = this.projectService.project$.pipe( - startWith(null), - combineLatestWith(this.route.params), - switchMap(() => this.getProjects()) + const projects$ = combineLatest([ + orgId$, + this.projectService.project$.pipe(startWith(null)), + ]).pipe(switchMap(([orgId]) => this.projectService.getProjects(orgId))); + + const secrets$ = combineLatest([orgId$, this.secretService.secret$.pipe(startWith(null))]).pipe( + switchMap(([orgId]) => this.secretService.getSecrets(orgId)) ); - const secrets$ = this.secretService.secret$.pipe( - startWith(null), - combineLatestWith(this.route.params), - switchMap(() => this.getSecrets()) - ); + const serviceAccounts$ = combineLatest([ + orgId$, + this.serviceAccountService.serviceAccount$.pipe(startWith(null)), + ]).pipe(switchMap(([orgId]) => this.serviceAccountService.getServiceAccounts(orgId))); - this.view$ = combineLatest([projects$, secrets$]).pipe( - map(([projects, secrets]) => { + this.view$ = combineLatest([projects$, secrets$, serviceAccounts$]).pipe( + map(([projects, secrets, serviceAccounts]) => { return { - allProjects: projects, - allSecrets: secrets, latestProjects: this.getRecentItems(projects, this.tableSize), latestSecrets: this.getRecentItems(secrets, this.tableSize), + allProjects: projects, + allSecrets: secrets, + tasks: { + importSecrets: secrets.length > 0, + createSecret: secrets.length > 0, + createProject: projects.length > 0, + createServiceAccount: serviceAccounts.length > 0, + }, }; }) ); @@ -111,10 +145,6 @@ export class OverviewComponent implements OnInit, OnDestroy { // Projects --- - private async getProjects(): Promise { - return await this.projectService.getProjects(this.organizationId); - } - openEditProject(projectId: string) { this.dialogService.open(ProjectDialogComponent, { data: { @@ -134,6 +164,14 @@ export class OverviewComponent implements OnInit, OnDestroy { }); } + openServiceAccountDialog() { + this.dialogService.open(ServiceAccountDialogComponent, { + data: { + organizationId: this.organizationId, + }, + }); + } + openDeleteProjectDialog(event: ProjectListView[]) { this.dialogService.open(ProjectDeleteDialogComponent, { data: { @@ -144,8 +182,13 @@ export class OverviewComponent implements OnInit, OnDestroy { // Secrets --- - private async getSecrets(): Promise { - return await this.secretService.getSecrets(this.organizationId); + openSecretDialog() { + this.dialogService.open(SecretDialogComponent, { + data: { + organizationId: this.organizationId, + operation: OperationType.Add, + }, + }); } openEditSecret(secretId: string) { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts index 9b824ee4d9..a526075518 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts @@ -2,12 +2,13 @@ import { NgModule } from "@angular/core"; import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; +import { OnboardingModule } from "./onboarding.module"; import { OverviewRoutingModule } from "./overview-routing.module"; import { OverviewComponent } from "./overview.component"; import { SectionComponent } from "./section.component"; @NgModule({ - imports: [SecretsManagerSharedModule, OverviewRoutingModule], + imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule], declarations: [OverviewComponent, SectionComponent], providers: [], }) diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index f698923268..a5766287bf 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -16,6 +16,7 @@ export * from "./link"; export * from "./menu"; export * from "./multi-select"; export * from "./navigation"; +export * from "./progress"; export * from "./radio-button"; export * from "./table"; export * from "./tabs";