[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
This commit is contained in:
Will Martin 2023-02-21 11:25:41 -05:00 committed by GitHub
parent 7ce4579214
commit 581f69256d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 408 additions and 68 deletions

View File

@ -6420,6 +6420,41 @@
"errorReadingImportFile": { "errorReadingImportFile": {
"message": "An error occurred when trying to read the import file" "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": { "restoreSecret": {
"message": "Restore secret" "message": "Restore secret"
}, },

View File

@ -0,0 +1,21 @@
<ng-template #content>
<i class="bwi bwi-fw !tw-mr-4" [ngClass]="completed ? 'bwi-check tw-text-success' : icon"></i
><span
[ngClass]="{
'tw-text-primary-700 tw-line-through tw-decoration-primary-700 tw-opacity-50': completed
}"
>{{ title }}<i class="bwi bwi-angle-right tw-ml-1"></i
></span>
</ng-template>
<li class="tw-list-none">
<a bitLink *ngIf="route" [routerLink]="route">
<ng-container *ngTemplateOutlet="content"></ng-container>
</a>
<button type="button" bitLink *ngIf="!route">
<ng-container *ngTemplateOutlet="content"></ng-container>
</button>
<div class="tw-ml-8 tw-mt-1 tw-text-sm" [ngClass]="{ 'tw-opacity-50': completed }">
<ng-content></ng-content>
</div>
</li>

View File

@ -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[];
}

View File

@ -0,0 +1,41 @@
<details
*ngIf="visible"
#details
class="tw-rounded-sm tw-bg-background-alt tw-text-main"
(toggle)="toggle()"
open
>
<summary class="tw-list-none tw-p-2 tw-px-4">
<div class="tw-flex tw-select-none tw-items-center tw-gap-4">
<i class="bwi bwi-dashboard tw-text-3xl tw-text-primary-500" aria-hidden="true"></i>
<div class="tw-text-lg">{{ title }}</div>
<bit-progress class="tw-flex-1" [showText]="false" [barWidth]="barWidth"></bit-progress>
<span *ngIf="tasks.length > 0; else spinner">
{{ "complete" | i18n: amountCompleted:tasks.length }}
</span>
<i
class="bwi tw-my-auto"
[ngClass]="open ? 'bwi-angle-down' : 'bwi-angle-up'"
aria-hidden="true"
></i>
</div>
</summary>
<ul class="tw-ml-6 tw-mb-0 tw-flex tw-flex-col tw-gap-4">
<ng-content></ng-content>
</ul>
<div class="tw-p-4 tw-pt-0">
<button
bitLink
type="button"
class="tw-ml-auto tw-block"
[ngClass]="{ 'tw-invisible': !isComplete }"
(click)="dismiss()"
>
Dismiss
</button>
</div>
</details>
<ng-template #spinner>
<i class="bwi bwi-spinner bwi-spin"></i>
</ng-template>

View File

@ -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<OnboardingTaskComponent>;
@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;
}
}

View File

@ -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 {}

View File

@ -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: `
<sm-onboarding title="Get started">
<sm-onboarding-task
[title]="'createServiceAccount' | i18n"
icon="bwi-cli"
[completed]="createServiceAccount"
>
<span>
{{ "downloadThe" | i18n }} <a bitLink routerLink="">{{ "smCLI" | i18n }}</a>
</span>
</sm-onboarding-task>
<sm-onboarding-task
[title]="'importSecrets' | i18n"
icon="bwi-download"
[completed]="importSecrets$ | async"
></sm-onboarding-task>
<sm-onboarding-task
[title]="'createSecret' | i18n"
icon="bwi-key"
[completed]="createSecret"
></sm-onboarding-task>
<sm-onboarding-task
[title]="'createProject' | i18n"
icon="bwi-collection"
[completed]="createProject"
></sm-onboarding-task>
</sm-onboarding>
`,
});
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)),
};

View File

@ -1,37 +1,68 @@
<sm-header [title]="organizationName"> <sm-header [title]="organizationName">
<sm-new-menu></sm-new-menu> <sm-new-menu></sm-new-menu>
</sm-header> </sm-header>
<div
*ngIf="view$ | async as view; else spinner" <div *ngIf="view$ | async as view; else spinner">
class="tw-flex tw-flex-col tw-gap-6 [&_sm-header]:tw-hidden" <sm-onboarding [title]="'getStarted' | i18n">
> <sm-onboarding-task
<sm-section> [title]="'createServiceAccount' | i18n"
<h2 slot="summary" class="tw-mb-0 tw-text-2xl tw-font-semibold">{{ "projects" | i18n }}</h2> (click)="openServiceAccountDialog()"
<sm-projects-list icon="bwi-cli"
(newProjectEvent)="openNewProjectDialog()" [completed]="view.tasks.createServiceAccount"
(editProjectEvent)="openEditProject($event)" >
(deleteProjectEvent)="openDeleteProjectDialog($event)" <span>
[projects]="view.latestProjects" {{ "downloadThe" | i18n }} <a bitLink routerLink="">{{ "smCLI" | i18n }}</a>
></sm-projects-list> </span>
<div *ngIf="view.allProjects.length > 0" class="tw-ml-auto tw-mt-4 tw-max-w-max"> </sm-onboarding-task>
{{ "showingPortionOfTotal" | i18n: view.latestProjects.length:view.allProjects.length }} <sm-onboarding-task
<a bitLink routerLink="projects" class="tw-ml-2">{{ "viewAll" | i18n }}</a> [title]="'importSecrets' | i18n"
</div> [route]="['settings', 'import']"
</sm-section> icon="bwi-download"
<sm-section> [completed]="view.tasks.importSecrets"
<h2 slot="summary" class="tw-mb-0 tw-text-2xl tw-font-semibold">{{ "secrets" | i18n }}</h2> ></sm-onboarding-task>
<sm-secrets-list <sm-onboarding-task
baseRoute="secrets" [title]="'createSecret' | i18n"
(deleteSecretsEvent)="openDeleteSecret($event)" (click)="openSecretDialog()"
(newSecretEvent)="openNewSecretDialog()" icon="bwi-key"
(editSecretEvent)="openEditSecret($event)" [completed]="view.tasks.createSecret"
[secrets]="view.latestSecrets" ></sm-onboarding-task>
></sm-secrets-list> <sm-onboarding-task
<div *ngIf="view.allSecrets.length > 0" class="tw-ml-auto tw-mt-4 tw-max-w-max"> [title]="'createProject' | i18n"
{{ "showingPortionOfTotal" | i18n: view.latestSecrets.length:view.allSecrets.length }} (click)="openNewProjectDialog()"
<a bitLink routerLink="secrets" class="tw-ml-2">{{ "viewAll" | i18n }}</a> icon="bwi-collection"
</div> [completed]="view.tasks.createProject"
</sm-section> ></sm-onboarding-task>
</sm-onboarding>
<div class="tw-mt-6 tw-flex tw-flex-col tw-gap-6">
<sm-section>
<h2 slot="summary" class="tw-mb-0 tw-text-2xl tw-font-semibold">{{ "projects" | i18n }}</h2>
<sm-projects-list
(newProjectEvent)="openNewProjectDialog()"
(editProjectEvent)="openEditProject($event)"
(deleteProjectEvent)="openDeleteProjectDialog($event)"
[projects]="view.latestProjects"
></sm-projects-list>
<div *ngIf="view.allProjects.length > 0" class="tw-ml-auto tw-mt-4 tw-max-w-max">
{{ "showingPortionOfTotal" | i18n: view.latestProjects.length:view.allProjects.length }}
<a bitLink routerLink="projects" class="tw-ml-2">{{ "viewAll" | i18n }}</a>
</div>
</sm-section>
<sm-section>
<h2 slot="summary" class="tw-mb-0 tw-text-2xl tw-font-semibold">{{ "secrets" | i18n }}</h2>
<sm-secrets-list
baseRoute="secrets"
(deleteSecretsEvent)="openDeleteSecret($event)"
(newSecretEvent)="openNewSecretDialog()"
(editSecretEvent)="openEditSecret($event)"
[secrets]="view.latestSecrets"
></sm-secrets-list>
<div *ngIf="view.allSecrets.length > 0" class="tw-ml-auto tw-mt-4 tw-max-w-max">
{{ "showingPortionOfTotal" | i18n: view.latestSecrets.length:view.allSecrets.length }}
<a bitLink routerLink="secrets" class="tw-ml-2">{{ "viewAll" | i18n }}</a>
</div>
</sm-section>
</div>
</div> </div>
<ng-template #spinner> <ng-template #spinner>

View File

@ -1,14 +1,14 @@
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { import {
combineLatest,
combineLatestWith,
map, map,
Observable, Observable,
startWith,
Subject,
switchMap, switchMap,
Subject,
takeUntil, takeUntil,
combineLatest,
startWith,
distinct,
} from "rxjs"; } from "rxjs";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
@ -21,7 +21,6 @@ import {
ProjectDeleteOperation, ProjectDeleteOperation,
} from "../projects/dialog/project-delete-dialog.component"; } from "../projects/dialog/project-delete-dialog.component";
import { import {
OperationType,
ProjectDialogComponent, ProjectDialogComponent,
ProjectOperation, ProjectOperation,
} from "../projects/dialog/project-dialog.component"; } from "../projects/dialog/project-dialog.component";
@ -30,41 +29,68 @@ import {
SecretDeleteDialogComponent, SecretDeleteDialogComponent,
SecretDeleteOperation, SecretDeleteOperation,
} from "../secrets/dialog/secret-delete.component"; } 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 { 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({ @Component({
selector: "sm-overview", selector: "sm-overview",
templateUrl: "./overview.component.html", templateUrl: "./overview.component.html",
}) })
export class OverviewComponent implements OnInit, OnDestroy { export class OverviewComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>(); private destroy$: Subject<void> = new Subject<void>();
/**
* Number of items to show in tables
*/
private tableSize = 10; private tableSize = 10;
private organizationId: string; private organizationId: string;
protected organizationName: string; protected organizationName: string;
protected view$: Observable<{ protected view$: Observable<{
allProjects: ProjectListView[]; allProjects: ProjectListView[];
allSecrets: SecretListView[]; allSecrets: SecretListView[];
latestProjects: ProjectListView[]; latestProjects: ProjectListView[];
latestSecrets: SecretListView[]; latestSecrets: SecretListView[];
tasks: Tasks;
}>; }>;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router,
private projectService: ProjectService, private projectService: ProjectService,
private secretService: SecretService,
private serviceAccountService: ServiceAccountService,
private dialogService: DialogService, private dialogService: DialogService,
private organizationService: OrganizationService, private organizationService: OrganizationService
private secretService: SecretService ) {
) {} /**
* 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() { ngOnInit() {
this.route.params const orgId$ = this.route.params.pipe(
map((p) => p.organizationId),
distinct()
);
orgId$
.pipe( .pipe(
map((params) => this.organizationService.get(params.organizationId)), map((orgId) => this.organizationService.get(orgId)),
takeUntil(this.destroy$) takeUntil(this.destroy$)
) )
.subscribe((org) => { .subscribe((org) => {
@ -72,25 +98,33 @@ export class OverviewComponent implements OnInit, OnDestroy {
this.organizationName = org.name; this.organizationName = org.name;
}); });
const projects$ = this.projectService.project$.pipe( const projects$ = combineLatest([
startWith(null), orgId$,
combineLatestWith(this.route.params), this.projectService.project$.pipe(startWith(null)),
switchMap(() => this.getProjects()) ]).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( const serviceAccounts$ = combineLatest([
startWith(null), orgId$,
combineLatestWith(this.route.params), this.serviceAccountService.serviceAccount$.pipe(startWith(null)),
switchMap(() => this.getSecrets()) ]).pipe(switchMap(([orgId]) => this.serviceAccountService.getServiceAccounts(orgId)));
);
this.view$ = combineLatest([projects$, secrets$]).pipe( this.view$ = combineLatest([projects$, secrets$, serviceAccounts$]).pipe(
map(([projects, secrets]) => { map(([projects, secrets, serviceAccounts]) => {
return { return {
allProjects: projects,
allSecrets: secrets,
latestProjects: this.getRecentItems(projects, this.tableSize), latestProjects: this.getRecentItems(projects, this.tableSize),
latestSecrets: this.getRecentItems(secrets, 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 --- // Projects ---
private async getProjects(): Promise<ProjectListView[]> {
return await this.projectService.getProjects(this.organizationId);
}
openEditProject(projectId: string) { openEditProject(projectId: string) {
this.dialogService.open<unknown, ProjectOperation>(ProjectDialogComponent, { this.dialogService.open<unknown, ProjectOperation>(ProjectDialogComponent, {
data: { data: {
@ -134,6 +164,14 @@ export class OverviewComponent implements OnInit, OnDestroy {
}); });
} }
openServiceAccountDialog() {
this.dialogService.open<unknown, ServiceAccountOperation>(ServiceAccountDialogComponent, {
data: {
organizationId: this.organizationId,
},
});
}
openDeleteProjectDialog(event: ProjectListView[]) { openDeleteProjectDialog(event: ProjectListView[]) {
this.dialogService.open<unknown, ProjectDeleteOperation>(ProjectDeleteDialogComponent, { this.dialogService.open<unknown, ProjectDeleteOperation>(ProjectDeleteDialogComponent, {
data: { data: {
@ -144,8 +182,13 @@ export class OverviewComponent implements OnInit, OnDestroy {
// Secrets --- // Secrets ---
private async getSecrets(): Promise<SecretListView[]> { openSecretDialog() {
return await this.secretService.getSecrets(this.organizationId); this.dialogService.open<unknown, SecretOperation>(SecretDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Add,
},
});
} }
openEditSecret(secretId: string) { openEditSecret(secretId: string) {

View File

@ -2,12 +2,13 @@ import { NgModule } from "@angular/core";
import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
import { OnboardingModule } from "./onboarding.module";
import { OverviewRoutingModule } from "./overview-routing.module"; import { OverviewRoutingModule } from "./overview-routing.module";
import { OverviewComponent } from "./overview.component"; import { OverviewComponent } from "./overview.component";
import { SectionComponent } from "./section.component"; import { SectionComponent } from "./section.component";
@NgModule({ @NgModule({
imports: [SecretsManagerSharedModule, OverviewRoutingModule], imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule],
declarations: [OverviewComponent, SectionComponent], declarations: [OverviewComponent, SectionComponent],
providers: [], providers: [],
}) })

View File

@ -16,6 +16,7 @@ export * from "./link";
export * from "./menu"; export * from "./menu";
export * from "./multi-select"; export * from "./multi-select";
export * from "./navigation"; export * from "./navigation";
export * from "./progress";
export * from "./radio-button"; export * from "./radio-button";
export * from "./table"; export * from "./table";
export * from "./tabs"; export * from "./tabs";