From 698ea0c7679166f69da5eecd3388229cb0ab8974 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Tue, 19 Mar 2024 15:42:27 +0100 Subject: [PATCH] feat(admin-ui): Expose `registerAlert` provider for custom UI alerts Relates to #2503 --- .../admin-ui/src/lib/core/src/core.module.ts | 24 ++- .../lib/core/src/extension/register-alert.ts | 22 +++ .../src/providers/alerts/alerts.service.ts | 143 ++++++++++++++++-- .../core/src/providers/modal/modal.service.ts | 4 +- .../admin-ui/src/lib/core/src/public_api.ts | 1 + 5 files changed, 163 insertions(+), 31 deletions(-) create mode 100644 packages/admin-ui/src/lib/core/src/extension/register-alert.ts diff --git a/packages/admin-ui/src/lib/core/src/core.module.ts b/packages/admin-ui/src/lib/core/src/core.module.ts index de9e97b84a..0811b1f8a7 100644 --- a/packages/admin-ui/src/lib/core/src/core.module.ts +++ b/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 { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { TranslateCompiler, TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { interval } from 'rxjs'; import { getAppConfig } from './app.config'; 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 { 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 { Permission } from './public_api'; import { registerDefaultFormInputs } from './shared/dynamic-form-inputs/default-form-inputs'; import { SharedModule } from './shared/shared.module'; -import { Permission } from './public_api'; @NgModule({ imports: [ @@ -71,8 +70,6 @@ export class CoreModule { private localStorageService: LocalStorageService, private titleService: Title, private alertsService: AlertsService, - private dataService: DataService, - private notificationService: NotificationService, ) { this.initUiLanguagesAndLocales(); this.initUiTitle(); @@ -121,18 +118,19 @@ export class CoreModule { } private initAlerts() { + const pendingUpdatesId = 'pending-search-index-updates'; this.alertsService.configureAlert({ - id: 'pending-search-index-updates', + id: pendingUpdatesId, requiredPermissions: [Permission.ReadCatalog, Permission.ReadProduct], - check: () => - this.dataService.product + check: context => + context.dataService.product .getPendingSearchIndexUpdates() .mapSingle(({ pendingSearchIndexUpdates }) => pendingSearchIndexUpdates), - recheckIntervalMs: 1000 * 30, + recheck: () => interval(1000 * 30), 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, }); }); @@ -142,7 +140,7 @@ export class CoreModule { translationVars: { count: data }, }), }); - this.alertsService.refresh(); + this.alertsService.refresh(pendingUpdatesId); } } diff --git a/packages/admin-ui/src/lib/core/src/extension/register-alert.ts b/packages/admin-ui/src/lib/core/src/extension/register-alert.ts new file mode 100644 index 0000000000..91aa2e8116 --- /dev/null +++ b/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], + }; +} diff --git a/packages/admin-ui/src/lib/core/src/providers/alerts/alerts.service.ts b/packages/admin-ui/src/lib/core/src/providers/alerts/alerts.service.ts index 8778494319..26e45c8e5c 100644 --- a/packages/admin-ui/src/lib/core/src/providers/alerts/alerts.service.ts +++ b/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 { BehaviorSubject, combineLatest, first, - interval, isObservable, Observable, of, @@ -13,15 +12,109 @@ import { } from 'rxjs'; import { filter, map, startWith, take } from 'rxjs/operators'; 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'; +/** + * @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 { + /** + * @description + * A unique identifier for the alert. + */ id: string; - check: () => T | Promise | Observable; - 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 | Observable; + /** + * @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; + /** + * @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[]; } @@ -36,16 +129,19 @@ export class Alert { activeAlert$: Observable; private hasRun$ = new BehaviorSubject(false); private data$ = new BehaviorSubject(undefined); - constructor(private config: AlertConfig) { - if (this.config.recheckIntervalMs) { - interval(this.config.recheckIntervalMs).subscribe(() => this.runCheck()); + constructor( + private config: AlertConfig, + private context: AlertContext, + ) { + if (this.config.recheck) { + this.config.recheck(this.context).subscribe(() => this.runCheck()); } this.activeAlert$ = combineLatest(this.data$, this.hasRun$).pipe( map(([data, hasRun]) => { if (!data) { return; } - const isAlert = this.config.isAlert(data); + const isAlert = this.config.isAlert(data, this.context); if (!isAlert) { return; } @@ -53,12 +149,12 @@ export class Alert { id: this.config.id, runAction: () => { if (!hasRun) { - this.config.action(data); + this.config.action(data, this.context); this.hasRun$.next(true); } }, hasRun, - label: this.config.label(data), + label: this.config.label(data, this.context), }; }), ); @@ -67,7 +163,7 @@ export class Alert { return this.config.id; } runCheck() { - const result = this.config.check(); + const result = this.config.check(this.context); if (result instanceof Promise) { result.then(data => this.data$.next(data)); } else if (isObservable(result)) { @@ -87,7 +183,13 @@ export class AlertsService { private alertsMap = new Map>(); private configUpdated = new Subject(); - 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( map(() => [...this.alertsMap.values()]), startWith([...this.alertsMap.values()]), @@ -108,7 +210,7 @@ export class AlertsService { .pipe(first()) .subscribe(hasPermissions => { if (hasPermissions) { - this.alertsMap.set(config.id, new Alert(config)); + this.alertsMap.set(config.id, new Alert(config, this.createContext())); this.configUpdated.next(); } }); @@ -131,4 +233,13 @@ export class AlertsService { this.alertsMap.forEach(config => config.runCheck()); } } + + protected createContext(): AlertContext { + return { + injector: this.injector, + dataService: this.dataService, + notificationService: this.notificationService, + modalService: this.modalService, + }; + } } diff --git a/packages/admin-ui/src/lib/core/src/providers/modal/modal.service.ts b/packages/admin-ui/src/lib/core/src/providers/modal/modal.service.ts index a5f3aa5603..6b7d1d7d30 100644 --- a/packages/admin-ui/src/lib/core/src/providers/modal/modal.service.ts +++ b/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: * * @example - * ```HTML + * ```ts * class MyDialog implements Dialog { * resolveWith: (result?: any) => void; * @@ -48,7 +48,7 @@ export class ModalService { * ``` * * @example - * ```HTML + * ```html * Title of the modal * *

diff --git a/packages/admin-ui/src/lib/core/src/public_api.ts b/packages/admin-ui/src/lib/core/src/public_api.ts index 6f285f92b0..912e3664fe 100644 --- a/packages/admin-ui/src/lib/core/src/public_api.ts +++ b/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/route.component'; export * from './extension/providers/page-metadata.service'; +export * from './extension/register-alert'; export * from './extension/register-bulk-action'; export * from './extension/register-custom-detail-component'; export * from './extension/register-dashboard-widget';