Sfoglia il codice sorgente

feat(admin-ui): Expose `registerAlert` provider for custom UI alerts

Relates to #2503
Michael Bromley 1 anno fa
parent
commit
698ea0c767

+ 11 - 13
packages/admin-ui/src/lib/core/src/core.module.ts

@@ -5,6 +5,7 @@ import { BrowserModule, Title } from '@angular/platform-browser';
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import { TranslateCompiler, TranslateLoader, TranslateModule } from '@ngx-translate/core';
 import { TranslateCompiler, TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { interval } from 'rxjs';
 
 
 import { getAppConfig } from './app.config';
 import { getAppConfig } from './app.config';
 import { getDefaultUiLanguage, getDefaultUiLocale } from './common/utilities/get-default-ui-language';
 import { getDefaultUiLanguage, getDefaultUiLocale } from './common/utilities/get-default-ui-language';
@@ -21,16 +22,14 @@ import { ThemeSwitcherComponent } from './components/theme-switcher/theme-switch
 import { UiLanguageSwitcherDialogComponent } from './components/ui-language-switcher-dialog/ui-language-switcher-dialog.component';
 import { UiLanguageSwitcherDialogComponent } from './components/ui-language-switcher-dialog/ui-language-switcher-dialog.component';
 import { UserMenuComponent } from './components/user-menu/user-menu.component';
 import { UserMenuComponent } from './components/user-menu/user-menu.component';
 import { DataModule } from './data/data.module';
 import { DataModule } from './data/data.module';
-import { DataService } from './data/providers/data.service';
 import { AlertsService } from './providers/alerts/alerts.service';
 import { AlertsService } from './providers/alerts/alerts.service';
 import { CustomHttpTranslationLoader } from './providers/i18n/custom-http-loader';
 import { CustomHttpTranslationLoader } from './providers/i18n/custom-http-loader';
 import { InjectableTranslateMessageFormatCompiler } from './providers/i18n/custom-message-format-compiler';
 import { InjectableTranslateMessageFormatCompiler } from './providers/i18n/custom-message-format-compiler';
 import { I18nService } from './providers/i18n/i18n.service';
 import { I18nService } from './providers/i18n/i18n.service';
 import { LocalStorageService } from './providers/local-storage/local-storage.service';
 import { LocalStorageService } from './providers/local-storage/local-storage.service';
-import { NotificationService } from './providers/notification/notification.service';
+import { Permission } from './public_api';
 import { registerDefaultFormInputs } from './shared/dynamic-form-inputs/default-form-inputs';
 import { registerDefaultFormInputs } from './shared/dynamic-form-inputs/default-form-inputs';
 import { SharedModule } from './shared/shared.module';
 import { SharedModule } from './shared/shared.module';
-import { Permission } from './public_api';
 
 
 @NgModule({
 @NgModule({
     imports: [
     imports: [
@@ -71,8 +70,6 @@ export class CoreModule {
         private localStorageService: LocalStorageService,
         private localStorageService: LocalStorageService,
         private titleService: Title,
         private titleService: Title,
         private alertsService: AlertsService,
         private alertsService: AlertsService,
-        private dataService: DataService,
-        private notificationService: NotificationService,
     ) {
     ) {
         this.initUiLanguagesAndLocales();
         this.initUiLanguagesAndLocales();
         this.initUiTitle();
         this.initUiTitle();
@@ -121,18 +118,19 @@ export class CoreModule {
     }
     }
 
 
     private initAlerts() {
     private initAlerts() {
+        const pendingUpdatesId = 'pending-search-index-updates';
         this.alertsService.configureAlert({
         this.alertsService.configureAlert({
-            id: 'pending-search-index-updates',
+            id: pendingUpdatesId,
             requiredPermissions: [Permission.ReadCatalog, Permission.ReadProduct],
             requiredPermissions: [Permission.ReadCatalog, Permission.ReadProduct],
-            check: () =>
-                this.dataService.product
+            check: context =>
+                context.dataService.product
                     .getPendingSearchIndexUpdates()
                     .getPendingSearchIndexUpdates()
                     .mapSingle(({ pendingSearchIndexUpdates }) => pendingSearchIndexUpdates),
                     .mapSingle(({ pendingSearchIndexUpdates }) => pendingSearchIndexUpdates),
-            recheckIntervalMs: 1000 * 30,
+            recheck: () => interval(1000 * 30),
             isAlert: data => 0 < data,
             isAlert: data => 0 < data,
-            action: data => {
-                this.dataService.product.runPendingSearchIndexUpdates().subscribe(value => {
-                    this.notificationService.info(_('catalog.running-search-index-updates'), {
+            action: (data, context) => {
+                context.dataService.product.runPendingSearchIndexUpdates().subscribe(() => {
+                    context.notificationService.info(_('catalog.running-search-index-updates'), {
                         count: data,
                         count: data,
                     });
                     });
                 });
                 });
@@ -142,7 +140,7 @@ export class CoreModule {
                 translationVars: { count: data },
                 translationVars: { count: data },
             }),
             }),
         });
         });
-        this.alertsService.refresh();
+        this.alertsService.refresh(pendingUpdatesId);
     }
     }
 }
 }
 
 

+ 22 - 0
packages/admin-ui/src/lib/core/src/extension/register-alert.ts

@@ -0,0 +1,22 @@
+import { APP_INITIALIZER, FactoryProvider } from '@angular/core';
+import { AlertConfig, AlertsService } from '../providers/alerts/alerts.service';
+
+/**
+ * @description
+ * Registers an alert which can be displayed in the Admin UI alert dropdown in the top bar.
+ * The alert is configured using the {@link AlertConfig} object.
+ *
+ * @since 2.2.0
+ * @docsCategory alerts
+ */
+export function registerAlert(config: AlertConfig): FactoryProvider {
+    return {
+        provide: APP_INITIALIZER,
+        multi: true,
+        useFactory: (alertsService: AlertsService) => () => {
+            alertsService.configureAlert(config);
+            alertsService.refresh(config.id);
+        },
+        deps: [AlertsService],
+    };
+}

+ 127 - 16
packages/admin-ui/src/lib/core/src/providers/alerts/alerts.service.ts

@@ -1,10 +1,9 @@
-import { Injectable } from '@angular/core';
+import { Injectable, Injector } from '@angular/core';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import {
 import {
     BehaviorSubject,
     BehaviorSubject,
     combineLatest,
     combineLatest,
     first,
     first,
-    interval,
     isObservable,
     isObservable,
     Observable,
     Observable,
     of,
     of,
@@ -13,15 +12,109 @@ import {
 } from 'rxjs';
 } from 'rxjs';
 import { filter, map, startWith, take } from 'rxjs/operators';
 import { filter, map, startWith, take } from 'rxjs/operators';
 import { Permission } from '../../common/generated-types';
 import { Permission } from '../../common/generated-types';
+import { DataService } from '../../data/providers/data.service';
+import { ModalService } from '../modal/modal.service';
+import { NotificationService } from '../notification/notification.service';
 import { PermissionsService } from '../permissions/permissions.service';
 import { PermissionsService } from '../permissions/permissions.service';
 
 
+/**
+ * @description
+ * The context object which is passed to the `check`, `isAlert` and `action` functions of an
+ * {@link AlertConfig} object.
+ */
+export interface AlertContext {
+    /**
+     * @description
+     * The Angular [Injector](https://angular.dev/api/core/Injector) which can be used to get instances
+     * of services and other providers available in the application.
+     */
+    injector: Injector;
+    /**
+     * @description
+     * The [DataService](/reference/admin-ui-api/services/data-service), which provides methods for querying the
+     * server-side data.
+     */
+    dataService: DataService;
+    /**
+     * @description
+     * The [NotificationService](/reference/admin-ui-api/services/notification-service), which provides methods for
+     * displaying notifications to the user.
+     */
+    notificationService: NotificationService;
+    /**
+     * @description
+     * The [ModalService](/reference/admin-ui-api/services/modal-service), which provides methods for
+     * opening modal dialogs.
+     */
+    modalService: ModalService;
+}
+
+/**
+ * @description
+ * A configuration object for an Admin UI alert.
+ *
+ * @since 2.2.0
+ * @docsCategory alerts
+ */
 export interface AlertConfig<T = any> {
 export interface AlertConfig<T = any> {
+    /**
+     * @description
+     * A unique identifier for the alert.
+     */
     id: string;
     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 } };
+    /**
+     * @description
+     * A function which is gets the data used to determine whether the alert should be shown.
+     * Typically, this function will query the server or some other remote data source.
+     *
+     * This function will be called once when the Admin UI app bootstraps, and can be also
+     * set to run at regular intervals by setting the `recheckIntervalMs` property.
+     */
+    check: (context: AlertContext) => T | Promise<T> | Observable<T>;
+    /**
+     * @description
+     * A function which returns an Observable which is used to determine when to re-run the `check`
+     * function. Whenever the observable emits, the `check` function will be called again.
+     *
+     * A basic time-interval-based recheck can be achieved by using the `interval` function from RxJS.
+     *
+     * @example
+     * ```ts
+     * import { interval } from 'rxjs';
+     *
+     * // ...
+     * recheck: () => interval(60_000)
+     * ```
+     *
+     * If this is not set, the `check` function will only be called once when the Admin UI app bootstraps.
+     *
+     * @default undefined
+     */
+    recheck?: (context: AlertContext) => Observable<any>;
+    /**
+     * @description
+     * A function which determines whether the alert should be shown based on the data returned by the `check`
+     * function.
+     */
+    isAlert: (data: T, context: AlertContext) => boolean;
+    /**
+     * @description
+     * A function which is called when the alert is clicked in the Admin UI.
+     */
+    action: (data: T, context: AlertContext) => void;
+    /**
+     * @description
+     * A function which returns the text used in the UI to describe the alert.
+     */
+    label: (
+        data: T,
+        context: AlertContext,
+    ) => { text: string; translationVars?: { [key: string]: string | number } };
+    /**
+     * @description
+     * A list of permissions which the current Administrator must have in order. If the current
+     * Administrator does not have these permissions, none of the other alert functions will be called.
+     */
     requiredPermissions?: Permission[];
     requiredPermissions?: Permission[];
 }
 }
 
 
@@ -36,16 +129,19 @@ export class Alert<T> {
     activeAlert$: Observable<ActiveAlert | undefined>;
     activeAlert$: Observable<ActiveAlert | undefined>;
     private hasRun$ = new BehaviorSubject(false);
     private hasRun$ = new BehaviorSubject(false);
     private data$ = new BehaviorSubject<T | undefined>(undefined);
     private data$ = new BehaviorSubject<T | undefined>(undefined);
-    constructor(private config: AlertConfig<T>) {
-        if (this.config.recheckIntervalMs) {
-            interval(this.config.recheckIntervalMs).subscribe(() => this.runCheck());
+    constructor(
+        private config: AlertConfig<T>,
+        private context: AlertContext,
+    ) {
+        if (this.config.recheck) {
+            this.config.recheck(this.context).subscribe(() => this.runCheck());
         }
         }
         this.activeAlert$ = combineLatest(this.data$, this.hasRun$).pipe(
         this.activeAlert$ = combineLatest(this.data$, this.hasRun$).pipe(
             map(([data, hasRun]) => {
             map(([data, hasRun]) => {
                 if (!data) {
                 if (!data) {
                     return;
                     return;
                 }
                 }
-                const isAlert = this.config.isAlert(data);
+                const isAlert = this.config.isAlert(data, this.context);
                 if (!isAlert) {
                 if (!isAlert) {
                     return;
                     return;
                 }
                 }
@@ -53,12 +149,12 @@ export class Alert<T> {
                     id: this.config.id,
                     id: this.config.id,
                     runAction: () => {
                     runAction: () => {
                         if (!hasRun) {
                         if (!hasRun) {
-                            this.config.action(data);
+                            this.config.action(data, this.context);
                             this.hasRun$.next(true);
                             this.hasRun$.next(true);
                         }
                         }
                     },
                     },
                     hasRun,
                     hasRun,
-                    label: this.config.label(data),
+                    label: this.config.label(data, this.context),
                 };
                 };
             }),
             }),
         );
         );
@@ -67,7 +163,7 @@ export class Alert<T> {
         return this.config.id;
         return this.config.id;
     }
     }
     runCheck() {
     runCheck() {
-        const result = this.config.check();
+        const result = this.config.check(this.context);
         if (result instanceof Promise) {
         if (result instanceof Promise) {
             result.then(data => this.data$.next(data));
             result.then(data => this.data$.next(data));
         } else if (isObservable(result)) {
         } else if (isObservable(result)) {
@@ -87,7 +183,13 @@ export class AlertsService {
     private alertsMap = new Map<string, Alert<any>>();
     private alertsMap = new Map<string, Alert<any>>();
     private configUpdated = new Subject<void>();
     private configUpdated = new Subject<void>();
 
 
-    constructor(private permissionsService: PermissionsService) {
+    constructor(
+        private permissionsService: PermissionsService,
+        private injector: Injector,
+        private dataService: DataService,
+        private notificationService: NotificationService,
+        private modalService: ModalService,
+    ) {
         const alerts$ = this.configUpdated.pipe(
         const alerts$ = this.configUpdated.pipe(
             map(() => [...this.alertsMap.values()]),
             map(() => [...this.alertsMap.values()]),
             startWith([...this.alertsMap.values()]),
             startWith([...this.alertsMap.values()]),
@@ -108,7 +210,7 @@ export class AlertsService {
             .pipe(first())
             .pipe(first())
             .subscribe(hasPermissions => {
             .subscribe(hasPermissions => {
                 if (hasPermissions) {
                 if (hasPermissions) {
-                    this.alertsMap.set(config.id, new Alert(config));
+                    this.alertsMap.set(config.id, new Alert(config, this.createContext()));
                     this.configUpdated.next();
                     this.configUpdated.next();
                 }
                 }
             });
             });
@@ -131,4 +233,13 @@ export class AlertsService {
             this.alertsMap.forEach(config => config.runCheck());
             this.alertsMap.forEach(config => config.runCheck());
         }
         }
     }
     }
+
+    protected createContext(): AlertContext {
+        return {
+            injector: this.injector,
+            dataService: this.dataService,
+            notificationService: this.notificationService,
+            modalService: this.modalService,
+        };
+    }
 }
 }

+ 2 - 2
packages/admin-ui/src/lib/core/src/providers/modal/modal.service.ts

@@ -31,7 +31,7 @@ export class ModalService {
      * displayed in the modal dialog. See example:
      * displayed in the modal dialog. See example:
      *
      *
      * @example
      * @example
-     * ```HTML
+     * ```ts
      * class MyDialog implements Dialog {
      * class MyDialog implements Dialog {
      *  resolveWith: (result?: any) => void;
      *  resolveWith: (result?: any) => void;
      *
      *
@@ -48,7 +48,7 @@ export class ModalService {
      * ```
      * ```
      *
      *
      * @example
      * @example
-     * ```HTML
+     * ```html
      * <ng-template vdrDialogTitle>Title of the modal</ng-template>
      * <ng-template vdrDialogTitle>Title of the modal</ng-template>
      *
      *
      * <p>
      * <p>

+ 1 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -81,6 +81,7 @@ export * from './extension/add-nav-menu-item';
 export * from './extension/components/angular-route.component';
 export * from './extension/components/angular-route.component';
 export * from './extension/components/route.component';
 export * from './extension/components/route.component';
 export * from './extension/providers/page-metadata.service';
 export * from './extension/providers/page-metadata.service';
+export * from './extension/register-alert';
 export * from './extension/register-bulk-action';
 export * from './extension/register-bulk-action';
 export * from './extension/register-custom-detail-component';
 export * from './extension/register-custom-detail-component';
 export * from './extension/register-dashboard-widget';
 export * from './extension/register-dashboard-widget';