Browse Source

feat(admin-ui): Implement general alerts system

Michael Bromley 2 years ago
parent
commit
71e7163c47

+ 0 - 17
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts

@@ -119,14 +119,6 @@ export class ProductListComponent
         });
     }
 
-    ngOnInit() {
-        super.ngOnInit();
-        this.dataService.product
-            .getPendingSearchIndexUpdates()
-            .mapSingle(({ pendingSearchIndexUpdates }) => pendingSearchIndexUpdates)
-            .subscribe(value => (this.pendingSearchIndexUpdates = value));
-    }
-
     rebuildSearchIndex() {
         this.dataService.product.reindex().subscribe(({ reindex }) => {
             this.notificationService.info(_('catalog.reindexing'));
@@ -145,15 +137,6 @@ export class ProductListComponent
         });
     }
 
-    runPendingSearchIndexUpdates() {
-        this.dataService.product.runPendingSearchIndexUpdates().subscribe(value => {
-            this.notificationService.info(_('catalog.running-search-index-updates'), {
-                count: this.pendingSearchIndexUpdates,
-            });
-            this.pendingSearchIndexUpdates = 0;
-        });
-    }
-
     deleteProduct(productId: string) {
         this.modalService
             .dialog({

+ 23 - 0
packages/admin-ui/src/lib/core/src/components/alerts/alerts.component.html

@@ -0,0 +1,23 @@
+<vdr-dropdown>
+    <button class="alerts-button" vdrDropdownTrigger>
+        <vdr-status-badge *ngIf="hasAlerts$ | async" [type]="'warning'"></vdr-status-badge>
+        <div class="user-circle">
+            <clr-icon shape="bell" size="16"></clr-icon>
+        </div>
+    </button>
+    <vdr-dropdown-menu vdrPosition="bottom-right">
+        <ng-container *ngIf="activeAlerts$ | async as activeAlerts">
+            <ng-container *ngIf="activeAlerts.length; else noAlerts">
+                <button *ngFor="let alert of activeAlerts" vdrDropdownItem (click)="alert.runAction()" [disabled]="alert.hasRun">
+                    <clr-icon shape="check is-success" *ngIf="alert.hasRun"></clr-icon>
+                    {{ alert.label.text | translate : alert.label.translationVars }}
+                </button>
+            </ng-container>
+        </ng-container>
+        <ng-template #noAlerts>
+            <div class="no-alerts">
+                <clr-icon shape="check" class="mr-1" /><span>{{ 'common.no-alerts' | translate }}</span>
+            </div></ng-template
+        >
+    </vdr-dropdown-menu>
+</vdr-dropdown>

+ 40 - 0
packages/admin-ui/src/lib/core/src/components/alerts/alerts.component.scss

@@ -0,0 +1,40 @@
+@import 'variables';
+
+.alerts-button {
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    border: none;
+    font-size: var(--font-size-sm);
+    width: 100%;
+    line-height: 100%;
+    height: 40px;
+    color: var(--color-text-100);
+    background-color: var(--color-page-header-item-bg);
+    border-radius: var(--border-radius-lg);
+
+    cursor: pointer;
+    padding: calc(var(--space-unit) * 1.5);
+
+    &:hover {
+        color: var(--color-text-200);
+    }
+}
+
+vdr-status-badge {
+    position: absolute;
+    top: 9px;
+    right: 9px;
+}
+
+.no-alerts {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 100%;
+    width: 100%;
+    color: var(--color-text-300);
+    font-size: var(--font-size-sm);
+    padding: 0 calc(var(--space-unit) * 1.5);
+}

+ 21 - 0
packages/admin-ui/src/lib/core/src/components/alerts/alerts.component.ts

@@ -0,0 +1,21 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { ActiveAlert, AlertsService } from '../../providers/alerts/alerts.service';
+
+@Component({
+    selector: 'vdr-alerts',
+    templateUrl: './alerts.component.html',
+    styleUrls: ['./alerts.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AlertsComponent {
+    protected hasAlerts$: Observable<boolean>;
+    protected activeAlerts$: Observable<ActiveAlert[]>;
+    constructor(protected alertsService: AlertsService) {
+        this.hasAlerts$ = alertsService.activeAlerts$.pipe(
+            map(alerts => alerts.filter(a => !a.hasRun).length > 0),
+        );
+        this.activeAlerts$ = alertsService.activeAlerts$;
+    }
+}

+ 3 - 0
packages/admin-ui/src/lib/core/src/components/app-shell/app-shell.component.html

@@ -35,6 +35,9 @@
                     <vdr-breadcrumb></vdr-breadcrumb>
                 </div>
                 <div class="universal-search flex-spacer"></div>
+                <div class="mr-1">
+                    <vdr-alerts></vdr-alerts>
+                </div>
                 <div>
                     <vdr-user-menu
                         [userName]="userName$ | async"

+ 1 - 1
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.scss

@@ -84,6 +84,6 @@ nav.main-nav {
     position: relative;
 }
 .nav-group vdr-status-badge {
-    left: 10px;
+    left: 27px;
     top: 6px;
 }

+ 34 - 0
packages/admin-ui/src/lib/core/src/core.module.ts

@@ -3,10 +3,12 @@ import { HttpClient } from '@angular/common/http';
 import { NgModule } from '@angular/core';
 import { BrowserModule, Title } from '@angular/platform-browser';
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import { TranslateCompiler, TranslateLoader, TranslateModule } from '@ngx-translate/core';
 
 import { getAppConfig } from './app.config';
 import { getDefaultUiLanguage } from './common/utilities/get-default-ui-language';
+import { AlertsComponent } from './components/alerts/alerts.component';
 import { AppShellComponent } from './components/app-shell/app-shell.component';
 import { BaseNavComponent } from './components/base-nav/base-nav.component';
 import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component';
@@ -19,10 +21,13 @@ import { ThemeSwitcherComponent } from './components/theme-switcher/theme-switch
 import { UiLanguageSwitcherDialogComponent } from './components/ui-language-switcher-dialog/ui-language-switcher-dialog.component';
 import { UserMenuComponent } from './components/user-menu/user-menu.component';
 import { DataModule } from './data/data.module';
+import { DataService } from './data/providers/data.service';
+import { AlertsService } from './providers/alerts/alerts.service';
 import { CustomHttpTranslationLoader } from './providers/i18n/custom-http-loader';
 import { InjectableTranslateMessageFormatCompiler } from './providers/i18n/custom-message-format-compiler';
 import { I18nService } from './providers/i18n/i18n.service';
 import { LocalStorageService } from './providers/local-storage/local-storage.service';
+import { NotificationService } from './providers/notification/notification.service';
 import { registerDefaultFormInputs } from './shared/dynamic-form-inputs/register-dynamic-input-components';
 import { SharedModule } from './shared/shared.module';
 
@@ -56,6 +61,7 @@ import { SharedModule } from './shared/shared.module';
         UiLanguageSwitcherDialogComponent,
         ChannelSwitcherComponent,
         ThemeSwitcherComponent,
+        AlertsComponent,
     ],
 })
 export class CoreModule {
@@ -63,9 +69,13 @@ export class CoreModule {
         private i18nService: I18nService,
         private localStorageService: LocalStorageService,
         private titleService: Title,
+        private alertsService: AlertsService,
+        private dataService: DataService,
+        private notificationService: NotificationService,
     ) {
         this.initUiLanguages();
         this.initUiTitle();
+        this.initAlerts();
     }
 
     private initUiLanguages() {
@@ -93,6 +103,30 @@ export class CoreModule {
 
         this.titleService.setTitle(title);
     }
+
+    private initAlerts() {
+        this.alertsService.configureAlert({
+            id: 'pending-search-index-updates',
+            check: () =>
+                this.dataService.product
+                    .getPendingSearchIndexUpdates()
+                    .mapSingle(({ pendingSearchIndexUpdates }) => pendingSearchIndexUpdates),
+            recheckIntervalMs: 1000 * 30,
+            isAlert: data => 0 < data,
+            action: data => {
+                this.dataService.product.runPendingSearchIndexUpdates().subscribe(value => {
+                    this.notificationService.info(_('catalog.running-search-index-updates'), {
+                        count: data,
+                    });
+                });
+            },
+            label: data => ({
+                text: _('catalog.run-pending-search-index-updates'),
+                translationVars: { count: data },
+            }),
+        });
+        this.alertsService.refresh();
+    }
 }
 
 export function HttpLoaderFactory(http: HttpClient, location: PlatformLocation) {

+ 105 - 0
packages/admin-ui/src/lib/core/src/providers/alerts/alerts.service.ts

@@ -0,0 +1,105 @@
+import { Injectable } from '@angular/core';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { BehaviorSubject, combineLatest, interval, Observable, of, Subject, switchMap } from 'rxjs';
+import { map, mapTo, startWith, take } from 'rxjs/operators';
+
+export interface AlertConfig<T = any> {
+    id: string;
+    check: () => T | Promise<T> | Observable<T>;
+    recheckIntervalMs?: number;
+    isAlert: (value: T) => boolean;
+    action: (data: T) => void;
+    label: (data: T) => { text: string; translationVars?: { [key: string]: string | number } };
+}
+
+export interface ActiveAlert {
+    id: string;
+    runAction: () => void;
+    hasRun: boolean;
+    label: { text: string; translationVars?: { [key: string]: string | number } };
+}
+
+export class Alert<T> {
+    activeAlert$: Observable<ActiveAlert | undefined>;
+    private hasRun$ = new BehaviorSubject(false);
+    private data$ = new BehaviorSubject<T | undefined>(undefined);
+    constructor(private config: AlertConfig<T>) {
+        if (this.config.recheckIntervalMs) {
+            interval(this.config.recheckIntervalMs).subscribe(() => this.runCheck());
+        }
+        this.activeAlert$ = combineLatest(this.data$, this.hasRun$).pipe(
+            map(([data, hasRun]) => {
+                if (!data) {
+                    return;
+                }
+                const isAlert = this.config.isAlert(data);
+                if (!isAlert) {
+                    return;
+                }
+                return {
+                    id: this.config.id,
+                    runAction: () => {
+                        if (!hasRun) {
+                            this.config.action(data);
+                            this.hasRun$.next(true);
+                        }
+                    },
+                    hasRun,
+                    label: this.config.label(data),
+                };
+            }),
+        );
+    }
+    get id() {
+        return this.config.id;
+    }
+    runCheck() {
+        const result = this.config.check();
+        if (result instanceof Promise) {
+            result.then(data => this.data$.next(data));
+        } else if (result instanceof Observable) {
+            result.pipe(take(1)).subscribe(data => this.data$.next(data));
+        } else {
+            this.data$.next(result);
+        }
+        this.hasRun$.next(false);
+    }
+}
+
+@Injectable({
+    providedIn: 'root',
+})
+export class AlertsService {
+    activeAlerts$: Observable<ActiveAlert[]>;
+    private alertsMap = new Map<string, Alert<any>>();
+    private configUpdated = new Subject<void>();
+
+    constructor() {
+        const alerts$ = this.configUpdated.pipe(
+            mapTo([...this.alertsMap.values()]),
+            startWith([...this.alertsMap.values()]),
+        );
+
+        this.activeAlerts$ = alerts$.pipe(
+            switchMap(() => {
+                const alerts = [...this.alertsMap.values()];
+                const isAlertStreams = alerts.map(alert => alert.activeAlert$);
+                return combineLatest(isAlertStreams);
+            }),
+            map(alertStates => alertStates.filter(notNullOrUndefined)),
+        );
+    }
+
+    configureAlert<T>(config: AlertConfig<T>) {
+        this.alertsMap.set(config.id, new Alert(config));
+        this.configUpdated.next();
+    }
+
+    refresh(id?: string) {
+        if (id) {
+            this.alertsMap.get(id)?.runCheck();
+        } else {
+            this.alertsMap.forEach(config => config.runCheck());
+        }
+    }
+}

+ 3 - 3
packages/admin-ui/src/lib/core/src/shared/components/status-badge/status-badge.component.scss

@@ -13,13 +13,13 @@
         background-color: var(--color-primary-600);
     }
     &.success {
-        background-color: var(--color-success-500);
+        background-color: var(--color-success-600);
     }
     &.warning {
-        background-color: var(--color-warning-500);
+        background-color: var(--color-warning-600);
     }
     &.error {
-        background-color: var(--color-error-400);
+        background-color: var(--color-error-600);
     }
 }
 

+ 0 - 2
packages/admin-ui/src/lib/customer/src/components/customer-history/customer-history.component.html

@@ -138,7 +138,6 @@
                                 </button>
                                 <vdr-dropdown-menu vdrPosition="bottom-right">
                                     <button
-                                        class="button"
                                         vdrDropdownItem
                                         (click)="updateNote.emit(entry)"
                                         [disabled]="!('UpdateCustomer' | hasPermission)"
@@ -148,7 +147,6 @@
                                     </button>
                                     <div class="dropdown-divider"></div>
                                     <button
-                                        class="button"
                                         vdrDropdownItem
                                         (click)="deleteNote.emit(entry)"
                                         [disabled]="!('UpdateCustomer' | hasPermission)"

+ 0 - 2
packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.html

@@ -177,7 +177,6 @@
                                 </button>
                                 <vdr-dropdown-menu vdrPosition="bottom-right">
                                     <button
-                                        class="button"
                                         vdrDropdownItem
                                         (click)="updateNote.emit(entry)"
                                         [disabled]="!('UpdateOrder' | hasPermission)"
@@ -187,7 +186,6 @@
                                     </button>
                                     <div class="dropdown-divider"></div>
                                     <button
-                                        class="button"
                                         vdrDropdownItem
                                         (click)="deleteNote.emit(entry)"
                                         [disabled]="!('UpdateOrder' | hasPermission)"

+ 1 - 0
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -159,6 +159,7 @@
     "reorder-collection": "Re-order collection",
     "root-collection": "Root collection",
     "running-search-index-updates": "Running {count, plural, one {1 update} other {{count} updates}} to search index",
+    "run-pending-search-index-updates": "Search index: run {count, plural, one {1 pending update} other {{count} pending updates}}",
     "search-asset-name-or-tag": "Search by asset name or tags",
     "search-for-term": "Search for term",
     "search-product-name-or-code": "Search by product name or code",