feat(console): onboarding flow (#5225)

Implements an onboarding UI for users
This commit is contained in:
Max Peintner
2023-02-24 18:01:05 +01:00
committed by GitHub
parent a7cc907ab7
commit f8ddc844f8
39 changed files with 1248 additions and 26 deletions
+40 -1
View File
@@ -212,7 +212,46 @@
<ng-template #shortcutKeyRef>
<ng-container *ngIf="(isHandset$ | async) === false">
<span class="fill-space"></span>
<ng-template cnslHasRole [hasRole]="['iam.read']">
<span class="fill-space"></span>
<ng-container *ngIf="!adminService.hideOnboarding && (adminService.progressAllDone | async) === false">
<button
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
matRipple
class="progress-bar"
(click)="showInstanceProgress = !showInstanceProgress"
>
<mat-progress-bar
class="progress"
mode="determinate"
[value]="adminService.progressPercentage | async"
></mat-progress-bar>
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[flexibleDimensions]="true"
[lockPosition]="true"
[cdkConnectedOverlayOffsetY]="10"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayPositions]="positions"
cdkConnectedOverlayBackdropClass="transparent-backdrop"
[cdkConnectedOverlayOpen]="showInstanceProgress"
(backdropClick)="showInstanceProgress = false"
(detach)="showInstanceProgress = false"
>
<cnsl-onboarding-card
(dismissedCard)="dismissOnboarding()"
class="onboarding_card"
*ngIf="org && showInstanceProgress"
>
</cnsl-onboarding-card>
</ng-template>
</ng-container>
</ng-template>
<div (click)="openHelp()" class="nav-shortcut-action-key" matTooltip="{{ 'MENU.OPENSHORTCUTSTOOLTIP' | translate }}">
<div class="nav-key-overlay"></div>
<span>?</span>
@@ -181,8 +181,39 @@
flex: 1;
}
.progress-bar {
margin-right: 0.5rem;
width: 100px;
min-width: 50px;
background: if($is-dark-theme, #ffffff20, #00000010);
border: none;
padding: 2px;
border-radius: 50vw;
&:hover {
background: if($is-dark-theme, #ffffff, $primary-color);
transition: background ease 0.15s;
}
.progress {
border-radius: 50vw;
height: 8px;
.mat-progress-bar-buffer {
background-color: if($is-dark-theme, rgb(69, 91, 84), #cccccc) !important;
}
.mat-progress-bar-background {
fill: if($is-dark-theme, rgb(69, 91, 84), #cccccc) !important;
}
.mat-progress-bar-fill:after {
background-color: var(--success);
}
}
}
.nav-shortcut-action-key {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
font-size: 12px;
@@ -213,3 +244,9 @@
}
}
}
.onboarding_card {
border-radius: 0.5rem;
box-shadow: 0 0 15px 0 rgb(0 0 0 / 10%);
border: 1px solid rgba(#8795a1, 0.2);
}
+19 -8
View File
@@ -1,17 +1,20 @@
import { animate, keyframes, style, transition, trigger } from '@angular/animations';
import { BreakpointObserver } from '@angular/cdk/layout';
import { ConnectedPosition, ConnectionPositionPair } from '@angular/cdk/overlay';
import { HttpClient } from '@angular/common/http';
import { Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core';
import { UntypedFormControl } from '@angular/forms';
import { Router } from '@angular/router';
import { BehaviorSubject, combineLatest, forkJoin, map, merge, Observable, Subject, switchMap, take } from 'rxjs';
import { BehaviorSubject, combineLatest, map, Observable, Subject, take, tap } from 'rxjs';
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
import { User } from 'src/app/proto/generated/zitadel/user_pb';
import { AdminService } from 'src/app/services/admin.service';
import { AuthenticationService } from 'src/app/services/authentication.service';
import { BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { KeyboardShortcutsService } from 'src/app/services/keyboard-shortcuts/keyboard-shortcuts.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { StorageLocation, StorageService } from 'src/app/services/storage.service';
@Component({
selector: 'cnsl-nav',
@@ -73,6 +76,7 @@ export class NavComponent implements OnDestroy {
@Input() public isDarkTheme: boolean = true;
@Input() public user!: User.AsObject;
public showInstanceProgress: boolean = false;
public isHandset$: Observable<boolean> = this.breakpointObserver.observe('(max-width: 599px)').pipe(
map((result) => {
return result.matches;
@@ -83,14 +87,19 @@ export class NavComponent implements OnDestroy {
public filterControl: UntypedFormControl = new UntypedFormControl('');
public orgLoading$: BehaviorSubject<any> = new BehaviorSubject(false);
public showAccount: boolean = false;
public hideAdminWarn: boolean = true;
private destroy$: Subject<void> = new Subject();
public BreadcrumbType: any = BreadcrumbType;
public customerPortalLink: string = '';
public positions: ConnectedPosition[] = [
new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }, 0, 10),
new ConnectionPositionPair({ originX: 'end', originY: 'bottom' }, { overlayX: 'end', overlayY: 'top' }, 0, 10),
];
constructor(
public authService: GrpcAuthService,
public adminService: AdminService,
public authenticationService: AuthenticationService,
public breadcrumbService: BreadcrumbService,
public mgmtService: ManagementService,
@@ -98,8 +107,8 @@ export class NavComponent implements OnDestroy {
private breakpointObserver: BreakpointObserver,
private http: HttpClient,
private shortcutService: KeyboardShortcutsService,
private storageService: StorageService,
) {
this.hideAdminWarn = localStorage.getItem('hideAdministratorWarning') === 'true' ? true : false;
this.loadEnvironment();
}
@@ -114,16 +123,18 @@ export class NavComponent implements OnDestroy {
});
}
public toggleAdminHide(): void {
this.hideAdminWarn = !this.hideAdminWarn;
localStorage.setItem('hideAdministratorWarning', this.hideAdminWarn.toString());
}
public ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
public dismissOnboarding(): void {
this.showInstanceProgress = false;
this.adminService.hideOnboarding = true;
this.storageService.setItem('onboarding-dismissed', 'true', StorageLocation.local);
this.adminService.progressAllDone.next(true);
}
public get isUserLinkActive(): boolean {
const url = this.router.url;
return url.substring(0, 6) === '/users';
@@ -12,12 +12,15 @@ import { TranslateModule } from '@ngx-translate/core';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { MatLegacyProgressBarModule } from '@angular/material/legacy-progress-bar';
import OnboardingCardModule from '../onboarding-card/onboarding-card.module';
import { NavComponent } from './nav.component';
@NgModule({
declarations: [NavComponent],
imports: [
CommonModule,
OnboardingCardModule,
FormsModule,
ReactiveFormsModule,
TranslateModule,
@@ -25,6 +28,7 @@ import { NavComponent } from './nav.component';
RouterModule,
MatTooltipModule,
HasRolePipeModule,
MatLegacyProgressBarModule,
HasRoleModule,
MatMenuModule,
MatButtonModule,
@@ -0,0 +1,39 @@
<div class="onboarding-card" cdkTrapFocus>
<div class="spinner-w">
<mat-spinner diameter="20" *ngIf="loading$ | async" color="accent"> </mat-spinner>
</div>
<div class="progress-header">
<h2>{{ 'ONBOARDING.CARD.TITLE' | translate }}</h2>
<p class="cnsl-secondary-text">
{{ 'ONBOARDING.CARD.DESCRIPTION' | translate }}
</p>
</div>
<div class="prog-desc cnsl-secondary-text">
{{ adminService.progressDone | async }} / {{ adminService.progressTotal | async }}
{{ 'ONBOARDING.COMPLETED' | translate }}
</div>
<div class="actions-list">
<ng-container *ngFor="let action of actions | async">
<a
[routerLink]="action[1].link"
[queryParams]="{ id: action[1].fragment }"
class="action-element"
[ngClass]="{ done: action[1].event !== undefined }"
>
<div class="state-circle">
<mat-icon *ngIf="action[1]?.event !== undefined" class="success-icon" matTooltip="{{ action[1].event | event }}"
>check_circle</mat-icon
>
</div>
<span class="name">{{ 'ONBOARDING.EVENTS.' + action[0] + '.title' | translate }}</span>
<mat-icon class="arrow-right">keyboard_arrow_right</mat-icon>
</a>
</ng-container>
</div>
<div class="no-thanks-wrapper">
<a class="no-thanks-btn" (click)="dismiss()">{{ 'ONBOARDING.DISMISS' | translate }}</a>
</div>
</div>
@@ -0,0 +1,144 @@
@mixin onboarding-card-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$accent: map-get($theme, accent);
$primary-color: mat.get-color-from-palette($primary, 500);
$warn-color: mat.get-color-from-palette($warn, 500);
$accent-color: mat.get-color-from-palette($accent, 500);
$foreground: map-get($theme, foreground);
$is-dark-theme: map-get($theme, is-dark);
$back: map-get($background, background);
.onboarding-card {
border-radius: 0.5rem;
z-index: 200;
position: relative;
min-width: 220px;
max-width: 280px;
padding-bottom: 0.5rem;
position: relative;
color: map-get($foreground, text);
background: map-get($background, cards);
.spinner-w {
top: 1rem;
left: 0;
right: 0;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
.progress-header {
padding: 1rem 1rem 0 1rem;
h2 {
margin-top: 0;
}
p {
font-size: 14px;
}
}
.prog-desc {
font-style: italic;
font-size: 14px;
padding: 0 1rem 1rem 1rem;
}
.actions-list {
display: flex;
flex-direction: column;
padding: 0 0 1rem 0;
.action-element {
display: flex;
align-items: center;
padding: 0.25rem 0.5rem 0.25rem 1rem;
text-decoration: none;
color: map-get($foreground, text);
.state-circle {
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
height: 1.5rem;
width: 1.5rem;
border-radius: 50vw;
margin-right: 1rem;
background-color: if($is-dark-theme, map-get($background, state), #e4e7e4);
box-shadow: 0 0 3px #0000001a;
i {
font-size: 1rem;
}
.success-icon {
font-size: 1.2rem;
height: 1.2rem;
width: 1.2rem;
color: var(--success);
}
.warn-icon {
font-size: 1.2rem;
height: 1.2rem;
width: 1.2rem;
color: map-get($background, alert);
}
}
.name {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.fill-space {
flex: 1;
}
.arrow-right {
visibility: hidden;
}
&.done {
.state-circle i {
color: var(--success);
}
.name {
text-decoration: line-through;
opacity: 0.5;
}
}
&:hover {
cursor: pointer;
background-color: #00000010;
.arrow-right {
visibility: visible;
}
}
}
}
.no-thanks-wrapper {
display: flex;
justify-content: flex-end;
padding: 0 1rem 0.5rem 1rem;
.no-thanks-btn {
font-style: italic;
cursor: pointer;
}
}
}
}
@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { OnboardingCardComponent } from './onboarding-card.component';
describe('OnboardingCardComponent', () => {
let component: OnboardingCardComponent;
let fixture: ComponentFixture<OnboardingCardComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [OnboardingCardComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(OnboardingCardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,26 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { AdminService } from 'src/app/services/admin.service';
import { ONBOARDING_EVENTS } from 'src/app/utils/onboarding';
@Component({
selector: 'cnsl-onboarding-card',
templateUrl: './onboarding-card.component.html',
styleUrls: ['./onboarding-card.component.scss'],
})
export class OnboardingCardComponent implements OnInit {
public percentageChanged: EventEmitter<number> = new EventEmitter<number>();
public loading$: BehaviorSubject<any> = new BehaviorSubject(false);
public actions = this.adminService.progressEvents;
@Output() public dismissedCard: EventEmitter<void> = new EventEmitter();
constructor(public adminService: AdminService) {}
public dismiss(): void {
this.dismissedCard.emit();
}
ngOnInit() {
this.adminService.loadEvents.next(ONBOARDING_EVENTS);
}
}
@@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatRippleModule } from '@angular/material/core';
import { MatIconModule } from '@angular/material/icon';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyProgressSpinnerModule as MatProgressSpinnerModule } from '@angular/material/legacy-progress-spinner';
import { MatLegacyTooltipModule as MatTooltipModule } from '@angular/material/legacy-tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { HasRoleModule } from 'src/app/directives/has-role/has-role.module';
import { ShortcutsModule } from 'src/app/modules/shortcuts/shortcuts.module';
import { RouterModule } from '@angular/router';
import { EventPipeModule } from 'src/app/pipes/event-pipe/event-pipe.module';
import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe/localized-date-pipe.module';
import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe/timestamp-to-date-pipe.module';
import { OnboardingCardComponent } from './onboarding-card.component';
@NgModule({
declarations: [OnboardingCardComponent],
imports: [
CommonModule,
MatIconModule,
TranslateModule,
RouterModule,
MatProgressSpinnerModule,
EventPipeModule,
MatTooltipModule,
],
exports: [OnboardingCardComponent],
})
export default class OnboardingCardModule {}
@@ -0,0 +1,57 @@
<div class="onboarding-header">
<h1 class="title" data-e2e="authenticated-welcome">{{ 'HOME.WELCOME' | translate }}</h1>
<p class="desc cnsl-secondary-text">{{ 'ONBOARDING.DESCRIPTION' | translate }}</p>
<ng-container *ngIf="!adminService.hideOnboarding && (adminService.progressAllDone | async) === false">
<div class="onboarding-progress-bar-wrapper">
<mat-progress-bar
class="progress"
mode="determinate"
[value]="adminService.progressPercentage | async"
></mat-progress-bar>
<div class="prog-desc cnsl-secondary-text">
{{ adminService.progressDone | async }} / {{ adminService.progressTotal | async }}
{{ 'ONBOARDING.COMPLETED' | translate }}
</div>
<mat-spinner diameter="20" *ngIf="adminService.onboardingLoading | async"></mat-spinner>
</div>
</ng-container>
<div
class="action-card-wrapper"
[ngClass]="{ alldone: adminService.hideOnboarding || (adminService.progressAllDone | async) }"
>
<ng-container *ngFor="let action of actions | async">
<a
[routerLink]="action[1].link"
[queryParams]="{ id: action[1].fragment }"
class="action-card card"
[ngClass]="{ done: action[1].event !== undefined }"
>
<div class="state-circle">
<mat-icon *ngIf="action[1]?.event !== undefined" matTooltip="{{ action[1].event | event }}" class="success-icon"
>check_circle</mat-icon
>
</div>
<div class="action-content">
<div class="text-block">
<span class="name">{{ 'ONBOARDING.EVENTS.' + action[0] + '.title' | translate }}</span>
<span class="cnsl-secondary-text description">{{
'ONBOARDING.EVENTS.' + action[0] + '.description' | translate
}}</span>
</div>
<span class="fill-space"></span>
<div class="action-row">
<span>{{ 'ACTIONS.SETUP' | translate }}</span>
<mat-icon class="icon">keyboard_arrow_right</mat-icon>
</div>
</div>
</a>
</ng-container>
</div>
</div>
@@ -0,0 +1,174 @@
@use '@angular/material' as mat;
@mixin onboarding-theme($theme) {
$primary: map-get($theme, primary);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$accent: map-get($theme, accent);
$primary-color: mat.get-color-from-palette($primary, 500);
$warn-color: mat.get-color-from-palette($warn, 500);
$accent-color: mat.get-color-from-palette($accent, 500);
$foreground: map-get($theme, foreground);
$is-dark-theme: map-get($theme, is-dark);
$back: map-get($background, background);
$list-background-color: mat.get-color-from-palette($background, 300);
$card-background-color: mat.get-color-from-palette($background, cards);
$border-color: if($is-dark-theme, rgba(#8795a1, 0.2), rgba(#8795a1, 0.2));
$border-selected-color: if($is-dark-theme, #fff, #000);
.onboarding-header {
display: flex;
flex-direction: column;
margin-bottom: 2rem;
.title {
font-size: 2rem;
margin-bottom: 1rem;
}
.desc {
font-size: 14px;
margin-top: 0;
}
.onboarding-progress-bar-wrapper {
display: flex;
align-items: center;
padding: 1rem 0;
min-height: 20px;
.progress {
border-radius: 50vw;
height: 8px;
max-width: 300px;
margin-right: 1rem;
.mat-progress-bar-buffer {
background-color: if($is-dark-theme, rgb(69, 91, 84), #cccccc) !important;
}
.mat-progress-bar-background {
fill: if($is-dark-theme, rgb(69, 91, 84), #cccccc) !important;
}
.mat-progress-bar-fill:after {
background-color: var(--success);
}
}
.prog-desc {
font-style: italic;
font-size: 14px;
margin-right: 1rem;
}
}
.action-card-wrapper {
display: flex;
flex-wrap: wrap;
margin: 0 -1rem;
box-sizing: border-box;
.action-card {
position: relative;
z-index: 100;
margin: 1rem;
flex-basis: 270px;
text-decoration: none;
cursor: pointer;
padding-top: 0;
padding-right: 0;
padding-bottom: 0;
padding-left: 1rem;
border-radius: 0.5rem;
box-sizing: border-box;
min-height: 166px;
transition: box-shadow 0.1s ease-in;
.action-content {
display: flex;
flex-direction: column;
height: 100%;
padding-right: 0.5rem;
.text-block {
display: flex;
flex-direction: column;
color: map-get($foreground, text);
padding-top: 1rem;
.name {
margin-bottom: 1rem;
}
.description {
font-size: 14px;
}
}
.fill-space {
flex: 1;
}
}
.state-circle {
position: absolute;
top: 0;
left: 0;
transform: translateX(-50%) translateY(-50%);
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
height: 1.5rem;
width: 1.5rem;
border-radius: 50vw;
margin-right: 1rem;
background-color: if($is-dark-theme, map-get($background, state), #e4e7e4);
box-shadow: 0 0 3px #0000001a;
i {
font-size: 1rem;
}
.success-icon {
font-size: 1.2rem;
height: 1.2rem;
width: 1.2rem;
color: var(--success);
}
.warn-icon {
font-size: 1.2rem;
height: 1.2rem;
width: 1.2rem;
color: map-get($foreground, text);
opacity: 0.3;
}
}
.action-row {
display: flex;
align-items: center;
justify-content: flex-end;
font-size: 14px;
margin-bottom: 0.5rem;
.icon {
margin-left: 0rem;
}
}
&:hover {
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.12);
}
}
&.alldone {
.state-circle {
display: none;
}
}
}
}
}
@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { OnboardingComponent } from './onboarding.component';
describe('OnboardingComponent', () => {
let component: OnboardingComponent;
let fixture: ComponentFixture<OnboardingComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [OnboardingComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(OnboardingComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import { AdminService } from 'src/app/services/admin.service';
import { ONBOARDING_EVENTS } from 'src/app/utils/onboarding';
@Component({
selector: 'cnsl-onboarding',
templateUrl: './onboarding.component.html',
styleUrls: ['./onboarding.component.scss'],
})
export class OnboardingComponent {
public actions = this.adminService.progressEvents;
constructor(public adminService: AdminService) {
this.adminService.loadEvents.next(ONBOARDING_EVENTS);
}
}
@@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatRippleModule } from '@angular/material/core';
import { MatIconModule } from '@angular/material/icon';
import { MatLegacyProgressSpinnerModule as MatProgressSpinnerModule } from '@angular/material/legacy-progress-spinner';
import { MatLegacyTooltipModule as MatTooltipModule } from '@angular/material/legacy-tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { ShortcutsModule } from 'src/app/modules/shortcuts/shortcuts.module';
import { MatLegacyProgressBarModule } from '@angular/material/legacy-progress-bar';
import { RouterModule } from '@angular/router';
import { EventPipeModule } from 'src/app/pipes/event-pipe/event-pipe.module';
import { OnboardingComponent } from './onboarding.component';
@NgModule({
declarations: [OnboardingComponent],
imports: [
CommonModule,
MatIconModule,
TranslateModule,
MatTooltipModule,
ShortcutsModule,
MatRippleModule,
RouterModule,
MatProgressSpinnerModule,
MatLegacyProgressBarModule,
EventPipeModule,
],
exports: [OnboardingComponent],
})
export default class OnboardingModule {}
@@ -14,7 +14,6 @@
.org-context-card {
border-radius: 0.5rem;
z-index: 200;
border: 1px solid #ffffff30;
display: flex;
flex-direction: column;
align-items: center;