Skip to content

Commit

Permalink
feat(admin-ui): Expose registerAlert provider for custom UI alerts
Browse files Browse the repository at this point in the history
Relates to #2503
  • Loading branch information
michaelbromley committed Mar 19, 2024
1 parent 7089a62 commit 698ea0c
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 31 deletions.
24 changes: 11 additions & 13 deletions packages/admin-ui/src/lib/core/src/core.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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: [
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
});
});
Expand All @@ -142,7 +140,7 @@ export class CoreModule {
translationVars: { count: data },
}),
});
this.alertsService.refresh();
this.alertsService.refresh(pendingUpdatesId);
}
}

Expand Down
22 changes: 22 additions & 0 deletions packages/admin-ui/src/lib/core/src/extension/register-alert.ts
Original file line number Diff line number Diff line change
@@ -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],
};
}
143 changes: 127 additions & 16 deletions packages/admin-ui/src/lib/core/src/providers/alerts/alerts.service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<T = any> {
/**
* @description
* A unique identifier for the alert.
*/
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[];
}

Expand All @@ -36,29 +129,32 @@ 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());
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(
map(([data, hasRun]) => {
if (!data) {
return;
}
const isAlert = this.config.isAlert(data);
const isAlert = this.config.isAlert(data, this.context);
if (!isAlert) {
return;
}
return {
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),
};
}),
);
Expand All @@ -67,7 +163,7 @@ export class Alert<T> {
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)) {
Expand All @@ -87,7 +183,13 @@ export class AlertsService {
private alertsMap = new Map<string, Alert<any>>();
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(
map(() => [...this.alertsMap.values()]),
startWith([...this.alertsMap.values()]),
Expand All @@ -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();
}
});
Expand All @@ -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,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
*
Expand All @@ -48,7 +48,7 @@ export class ModalService {
* ```
*
* @example
* ```HTML
* ```html
* <ng-template vdrDialogTitle>Title of the modal</ng-template>
*
* <p>
Expand Down
1 change: 1 addition & 0 deletions packages/admin-ui/src/lib/core/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit 698ea0c

Please sign in to comment.