Merge pull request #2025 from atmire/w2p-97425_System-wide-alerts

System-wide alerts
This commit is contained in:
Tim Donohue
2023-02-09 17:10:56 -06:00
committed by GitHub
20 changed files with 1261 additions and 0 deletions

View File

@@ -47,6 +47,12 @@ import { BatchImportPageComponent } from './admin-import-batch-page/batch-import
component: BatchImportPageComponent, component: BatchImportPageComponent,
data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' } data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' }
}, },
{
path: 'system-wide-alert',
resolve: { breadcrumb: I18nBreadcrumbResolver },
loadChildren: () => import('../system-wide-alert/system-wide-alert.module').then((m) => m.SystemWideAlertModule),
data: {title: 'admin.system-wide-alert.title', breadcrumbKey: 'admin.system-wide-alert'}
},
]) ])
], ],
providers: [ providers: [

View File

@@ -0,0 +1,13 @@
import { SystemWideAlertDataService } from './system-wide-alert-data.service';
import { testFindAllDataImplementation } from './base/find-all-data.spec';
import { testPutDataImplementation } from './base/put-data.spec';
import { testCreateDataImplementation } from './base/create-data.spec';
describe('SystemWideAlertDataService', () => {
describe('composition', () => {
const initService = () => new SystemWideAlertDataService(null, null, null, null, null);
testFindAllDataImplementation(initService);
testPutDataImplementation(initService);
testCreateDataImplementation(initService);
});
});

View File

@@ -0,0 +1,104 @@
import { Injectable } from '@angular/core';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Observable } from 'rxjs';
import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { FindAllData, FindAllDataImpl } from './base/find-all-data';
import { FindListOptions } from './find-list-options.model';
import { dataService } from './base/data-service.decorator';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { CreateData, CreateDataImpl } from './base/create-data';
import { SYSTEMWIDEALERT } from '../../system-wide-alert/system-wide-alert.resource-type';
import { SystemWideAlert } from '../../system-wide-alert/system-wide-alert.model';
import { PutData, PutDataImpl } from './base/put-data';
import { RequestParam } from '../cache/models/request-param.model';
import { SearchData, SearchDataImpl } from './base/search-data';
/**
* Dataservice representing a system-wide alert
*/
@Injectable()
@dataService(SYSTEMWIDEALERT)
export class SystemWideAlertDataService extends IdentifiableDataService<SystemWideAlert> implements FindAllData<SystemWideAlert>, CreateData<SystemWideAlert>, PutData<SystemWideAlert>, SearchData<SystemWideAlert> {
private findAllData: FindAllDataImpl<SystemWideAlert>;
private createData: CreateDataImpl<SystemWideAlert>;
private putData: PutDataImpl<SystemWideAlert>;
private searchData: SearchData<SystemWideAlert>;
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
) {
super('systemwidealerts', requestService, rdbService, objectCache, halService);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}
/**
* Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
* info should be added to the objects
*
* @param options Find list options object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>>}
* Return an observable that emits object list
*/
findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<SystemWideAlert>[]): Observable<RemoteData<PaginatedList<SystemWideAlert>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Create a new object on the server, and store the response in the object cache
*
* @param object The object to create
* @param params Array with additional params to combine with query string
*/
create(object: SystemWideAlert, ...params: RequestParam[]): Observable<RemoteData<SystemWideAlert>> {
return this.createData.create(object, ...params);
}
/**
* Send a PUT request for the specified object
*
* @param object The object to send a put request for.
*/
put(object: SystemWideAlert): Observable<RemoteData<SystemWideAlert>> {
return this.putData.put(object);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<SystemWideAlert>[]): Observable<RemoteData<PaginatedList<SystemWideAlert>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
}

View File

@@ -607,6 +607,18 @@ export class MenuResolver implements Resolve<boolean> {
icon: 'user-check', icon: 'user-check',
index: 11 index: 11
}, },
{
id: 'system_wide_alert',
active: false,
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.system-wide-alert',
link: '/admin/system-wide-alert'
} as LinkMenuItemModel,
icon: 'exclamation-circle',
index: 12
},
]; ];
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, { menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, {

View File

@@ -43,11 +43,13 @@ import {
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
import { PageErrorComponent } from './page-error/page-error.component'; import { PageErrorComponent } from './page-error/page-error.component';
import { ContextHelpToggleComponent } from './header/context-help-toggle/context-help-toggle.component'; import { ContextHelpToggleComponent } from './header/context-help-toggle/context-help-toggle.component';
import { SystemWideAlertModule } from './system-wide-alert/system-wide-alert.module';
const IMPORTS = [ const IMPORTS = [
CommonModule, CommonModule,
SharedModule.withEntryComponents(), SharedModule.withEntryComponents(),
NavbarModule, NavbarModule,
SystemWideAlertModule,
NgbModule, NgbModule,
]; ];

View File

@@ -4,6 +4,7 @@
}"> }">
<ds-themed-admin-sidebar></ds-themed-admin-sidebar> <ds-themed-admin-sidebar></ds-themed-admin-sidebar>
<div class="inner-wrapper"> <div class="inner-wrapper">
<ds-system-wide-alert-banner></ds-system-wide-alert-banner>
<ds-themed-header-navbar-wrapper></ds-themed-header-navbar-wrapper> <ds-themed-header-navbar-wrapper></ds-themed-header-navbar-wrapper>
<main class="main-content"> <main class="main-content">
<ds-themed-breadcrumbs></ds-themed-breadcrumbs> <ds-themed-breadcrumbs></ds-themed-breadcrumbs>

View File

@@ -0,0 +1,27 @@
<div *ngIf="(systemWideAlert$ |async)?.active">
<div class="rounded-0 alert alert-warning w100">
<div class="container">
<span class="font-weight-bold">
<span *ngIf="(countDownDays|async) > 0 || (countDownHours| async) > 0 || (countDownMinutes|async) > 0 ">
{{'system-wide-alert-banner.countdown.prefix' | translate }}
</span>
<span *ngIf="(countDownDays|async) > 0">
{{'system-wide-alert-banner.countdown.days' | translate: {
days: countDownDays|async
} }}
</span>
<span *ngIf="(countDownDays|async) > 0 || (countDownHours| async) > 0 ">
{{'system-wide-alert-banner.countdown.hours' | translate: {
hours: countDownHours| async
} }}
</span>
<span *ngIf="(countDownDays|async) > 0 || (countDownHours| async) > 0 || (countDownMinutes|async) > 0 ">
{{'system-wide-alert-banner.countdown.minutes' | translate: {
minutes: countDownMinutes|async
} }}
</span>
</span>
<span>{{(systemWideAlert$ |async)?.message}}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,114 @@
import { ComponentFixture, discardPeriodicTasks, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { SystemWideAlertBannerComponent } from './system-wide-alert-banner.component';
import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service';
import { SystemWideAlert } from '../system-wide-alert.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { utcToZonedTime } from 'date-fns-tz';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { TestScheduler } from 'rxjs/testing';
import { getTestScheduler } from 'jasmine-marbles';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
describe('SystemWideAlertBannerComponent', () => {
let comp: SystemWideAlertBannerComponent;
let fixture: ComponentFixture<SystemWideAlertBannerComponent>;
let systemWideAlertDataService: SystemWideAlertDataService;
let systemWideAlert: SystemWideAlert;
let scheduler: TestScheduler;
beforeEach(waitForAsync(() => {
scheduler = getTestScheduler();
const countDownDate = new Date();
countDownDate.setDate(countDownDate.getDate() + 1);
countDownDate.setHours(countDownDate.getHours() + 1);
countDownDate.setMinutes(countDownDate.getMinutes() + 1);
systemWideAlert = Object.assign(new SystemWideAlert(), {
alertId: 1,
message: 'Test alert message',
active: true,
countdownTo: utcToZonedTime(countDownDate, 'UTC').toISOString()
});
systemWideAlertDataService = jasmine.createSpyObj('systemWideAlertDataService', {
searchBy: createSuccessfulRemoteDataObject$(createPaginatedList([systemWideAlert])),
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [SystemWideAlertBannerComponent],
providers: [
{provide: SystemWideAlertDataService, useValue: systemWideAlertDataService},
{provide: NotificationsService, useValue: new NotificationsServiceStub()},
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SystemWideAlertBannerComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('init', () => {
it('should init the comp', () => {
expect(comp).toBeTruthy();
});
it('should set the time countdown parts in their respective behaviour subjects', fakeAsync(() => {
spyOn(comp.countDownDays, 'next');
spyOn(comp.countDownHours, 'next');
spyOn(comp.countDownMinutes, 'next');
comp.ngOnInit();
tick(2000);
expect(comp.countDownDays.next).toHaveBeenCalled();
expect(comp.countDownHours.next).toHaveBeenCalled();
expect(comp.countDownMinutes.next).toHaveBeenCalled();
discardPeriodicTasks();
}));
});
describe('banner', () => {
it('should display the alert message and the timer', () => {
comp.countDownDays.next(1);
comp.countDownHours.next(1);
comp.countDownMinutes.next(1);
fixture.detectChanges();
const banner = fixture.debugElement.queryAll(By.css('span'));
expect(banner.length).toEqual(6);
expect(banner[0].nativeElement.innerHTML).toContain('system-wide-alert-banner.countdown.prefix');
expect(banner[0].nativeElement.innerHTML).toContain('system-wide-alert-banner.countdown.days');
expect(banner[0].nativeElement.innerHTML).toContain('system-wide-alert-banner.countdown.hours');
expect(banner[0].nativeElement.innerHTML).toContain('system-wide-alert-banner.countdown.minutes');
expect(banner[5].nativeElement.innerHTML).toContain(systemWideAlert.message);
});
it('should display the alert message but no timer when no timer is present', () => {
comp.countDownDays.next(0);
comp.countDownHours.next(0);
comp.countDownMinutes.next(0);
fixture.detectChanges();
const banner = fixture.debugElement.queryAll(By.css('span'));
expect(banner.length).toEqual(2);
expect(banner[1].nativeElement.innerHTML).toContain(systemWideAlert.message);
});
it('should not display an alert when none is present', () => {
comp.systemWideAlert$.next(null);
fixture.detectChanges();
const banner = fixture.debugElement.queryAll(By.css('span'));
expect(banner.length).toEqual(0);
});
});
});

View File

@@ -0,0 +1,125 @@
import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service';
import {
getAllSucceededRemoteDataPayload
} from '../../core/shared/operators';
import { filter, map, switchMap } from 'rxjs/operators';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { SystemWideAlert } from '../system-wide-alert.model';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { BehaviorSubject, EMPTY, interval, Subscription } from 'rxjs';
import { zonedTimeToUtc } from 'date-fns-tz';
import { isPlatformBrowser } from '@angular/common';
import { NotificationsService } from '../../shared/notifications/notifications.service';
/**
* Component responsible for rendering a banner and the countdown for an active system-wide alert
*/
@Component({
selector: 'ds-system-wide-alert-banner',
styleUrls: ['./system-wide-alert-banner.component.scss'],
templateUrl: './system-wide-alert-banner.component.html'
})
export class SystemWideAlertBannerComponent implements OnInit, OnDestroy {
/**
* BehaviorSubject that keeps track of the currently configured system-wide alert
*/
systemWideAlert$ = new BehaviorSubject<SystemWideAlert>(undefined);
/**
* BehaviorSubject that keeps track of the amount of minutes left to count down to
*/
countDownMinutes = new BehaviorSubject<number>(0);
/**
* BehaviorSubject that keeps track of the amount of hours left to count down to
*/
countDownHours = new BehaviorSubject<number>(0);
/**
* BehaviorSubject that keeps track of the amount of days left to count down to
*/
countDownDays = new BehaviorSubject<number>(0);
/**
* List of subscriptions
*/
subscriptions: Subscription[] = [];
constructor(
@Inject(PLATFORM_ID) protected platformId: Object,
protected systemWideAlertDataService: SystemWideAlertDataService,
protected notificationsService: NotificationsService,
) {
}
ngOnInit() {
this.subscriptions.push(this.systemWideAlertDataService.searchBy('active').pipe(
getAllSucceededRemoteDataPayload(),
map((payload: PaginatedList<SystemWideAlert>) => payload.page),
filter((page) => isNotEmpty(page)),
map((page) => page[0])
).subscribe((alert: SystemWideAlert) => {
this.systemWideAlert$.next(alert);
}));
this.subscriptions.push(this.systemWideAlert$.pipe(
switchMap((alert: SystemWideAlert) => {
if (hasValue(alert) && hasValue(alert.countdownTo)) {
const date = zonedTimeToUtc(alert.countdownTo, 'UTC');
const timeDifference = date.getTime() - new Date().getTime();
if (timeDifference > 0) {
this.allocateTimeUnits(timeDifference);
if (isPlatformBrowser(this.platformId)) {
return interval(1000);
} else {
return EMPTY;
}
}
}
// Reset the countDown times to 0 and return EMPTY to prevent unnecessary countdown calculations
this.countDownDays.next(0);
this.countDownHours.next(0);
this.countDownMinutes.next(0);
return EMPTY;
})
).subscribe(() => {
this.setTimeDifference(this.systemWideAlert$.getValue().countdownTo);
}));
}
/**
* Helper method to calculate the time difference between the countdown date from the system-wide alert and "now"
* @param countdownTo - The date to count down to
*/
private setTimeDifference(countdownTo: string) {
const date = zonedTimeToUtc(countdownTo, 'UTC');
const timeDifference = date.getTime() - new Date().getTime();
this.allocateTimeUnits(timeDifference);
}
/**
* Helper method to push how many days, hours and minutes are left in the countdown to their respective behaviour subject
* @param timeDifference - The time difference to calculate and push the time units for
*/
private allocateTimeUnits(timeDifference) {
const minutes = Math.floor((timeDifference) / (1000 * 60) % 60);
const hours = Math.floor((timeDifference) / (1000 * 60 * 60) % 24);
const days = Math.floor((timeDifference) / (1000 * 60 * 60 * 24));
this.countDownMinutes.next(minutes);
this.countDownHours.next(hours);
this.countDownDays.next(days);
}
ngOnDestroy(): void {
this.subscriptions.forEach((sub: Subscription) => {
if (hasValue(sub)) {
sub.unsubscribe();
}
});
}
}

View File

@@ -0,0 +1,114 @@
<div class="container">
<h2 id="header">{{'system-wide-alert.form.header' | translate}}</h2>
<div [formGroup]="alertForm" [class]="'ng-invalid'">
<div class="form-group">
<div class="row mb-2">
<div class="col">
<ui-switch [checkedLabel]="'system-wide-alert.form.label.active' | translate"
[uncheckedLabel]="'system-wide-alert.form.label.inactive' | translate"
[checked]="formActive.value"
(change)="setActive($event)"></ui-switch>
</div>
</div>
<div class="row">
<div class="col">
<label for="formMessage">{{ 'system-wide-alert.form.label.message' | translate }}</label>
<textarea id="formMessage" rows="5"
[className]="(formMessage.invalid) && (formMessage.dirty || formMessage.touched) ? 'form-control is-invalid' :'form-control'"
formControlName="formMessage">
</textarea>
<div *ngIf="formMessage.invalid && (formMessage.dirty || formMessage.touched)"
class="invalid-feedback show-feedback">
<span *ngIf="formMessage.errors">
{{ 'system-wide-alert.form.error.message' | translate }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col mb-2 d-flex align-items-end">
<ui-switch size="small"
[checked]="counterEnabled$ |async"
(change)="setCounterEnabled($event)"></ui-switch>
<span class="ml-2">{{ 'system-wide-alert.form.label.countdownTo.enable' | translate }}</span>
</div>
</div>
<div *ngIf="counterEnabled$ |async">
<div class="row">
<div class="col-sm-12 col-md-6">
<div class="input-group">
<input
class="form-control"
placeholder="yyyy-mm-dd"
name="dp"
[(ngModel)]="date"
[minDate]="minDate"
ngbDatepicker
#d="ngbDatepicker"
(ngModelChange)="updatePreviewTime()"
/>
<button class="btn btn-outline-secondary fas fa-calendar" (click)="d.toggle()"
type="button"></button>
</div>
</div>
<div class="col-12 d-md-none">
<div class="input-group">
<ngb-timepicker [(ngModel)]="time" (ngModelChange)="updatePreviewTime()"></ngb-timepicker>
</div>
</div>
<div class="d-none d-md-block col-md-6 timepicker-margin">
<div class="input-group">
<ngb-timepicker [(ngModel)]="time" (ngModelChange)="updatePreviewTime()"></ngb-timepicker>
</div>
</div>
</div>
</div>
<div class="mb-2">
<span class="text-muted"> {{'system-wide-alert.form.label.countdownTo.hint' | translate}}</span>
</div>
<div *ngIf="formMessage.value">
<div class="row">
<div class="col">
<label>{{ 'system-wide-alert.form.label.preview' | translate }}</label>
</div>
</div>
<div class="rounded-0 alert alert-warning">
<span class="font-weight-bold">
<span *ngIf="previewDays > 0 || previewHours > 0 || previewMinutes > 0 ">
{{'system-wide-alert-banner.countdown.prefix' | translate }}
</span>
<span *ngIf="previewDays > 0">
{{'system-wide-alert-banner.countdown.days' | translate: {
days: previewDays
} }}
</span>
<span *ngIf="previewDays > 0 || previewHours > 0 ">
{{'system-wide-alert-banner.countdown.hours' | translate: {
hours: previewHours
} }}
</span>
<span *ngIf="previewDays > 0 || previewHours > 0 || previewMinutes > 0 ">
{{'system-wide-alert-banner.countdown.minutes' | translate: {
minutes: previewMinutes
} }}
</span>
</span>
<span>{{formMessage.value}}</span>
</div>
</div>
<div class="btn-row float-right space-children-mr mt-2">
<button (click)="back()"
class="btn btn-outline-secondary"><i
class="fas fa-arrow-left"></i> {{'system-wide-alert.form.cancel' | translate}}</button>
<button class="btn btn-primary" [disabled]="alertForm.invalid"
(click)="save()">
<i class="fa fa-save"></i> {{ 'system-wide-alert.form.save' | translate}}
</button>
</div>
</div>

View File

@@ -0,0 +1,4 @@
.timepicker-margin {
// Negative margin to offset the time picker arrows and ensure the date and time are correctly aligned
margin-top: -38px;
}

View File

@@ -0,0 +1,314 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service';
import { SystemWideAlert } from '../system-wide-alert.model';
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { TranslateModule } from '@ngx-translate/core';
import { SystemWideAlertFormComponent } from './system-wide-alert-form.component';
import { RequestService } from '../../core/data/request.service';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { RouterStub } from '../../shared/testing/router.stub';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { UiSwitchModule } from 'ngx-ui-switch';
import { SystemWideAlertModule } from '../system-wide-alert.module';
describe('SystemWideAlertFormComponent', () => {
let comp: SystemWideAlertFormComponent;
let fixture: ComponentFixture<SystemWideAlertFormComponent>;
let systemWideAlertDataService: SystemWideAlertDataService;
let systemWideAlert: SystemWideAlert;
let requestService: RequestService;
let notificationsService;
let router;
beforeEach(waitForAsync(() => {
const countDownDate = new Date();
countDownDate.setDate(countDownDate.getDate() + 1);
countDownDate.setHours(countDownDate.getHours() + 1);
countDownDate.setMinutes(countDownDate.getMinutes() + 1);
systemWideAlert = Object.assign(new SystemWideAlert(), {
alertId: 1,
message: 'Test alert message',
active: true,
countdownTo: utcToZonedTime(countDownDate, 'UTC').toISOString()
});
systemWideAlertDataService = jasmine.createSpyObj('systemWideAlertDataService', {
findAll: createSuccessfulRemoteDataObject$(createPaginatedList([systemWideAlert])),
put: createSuccessfulRemoteDataObject$(systemWideAlert),
create: createSuccessfulRemoteDataObject$(systemWideAlert)
});
requestService = jasmine.createSpyObj('requestService', ['setStaleByHrefSubstring']);
notificationsService = new NotificationsServiceStub();
router = new RouterStub();
TestBed.configureTestingModule({
imports: [FormsModule, SystemWideAlertModule, UiSwitchModule, TranslateModule.forRoot()],
declarations: [SystemWideAlertFormComponent],
providers: [
{provide: SystemWideAlertDataService, useValue: systemWideAlertDataService},
{provide: NotificationsService, useValue: notificationsService},
{provide: Router, useValue: router},
{provide: RequestService, useValue: requestService},
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SystemWideAlertFormComponent);
comp = fixture.componentInstance;
spyOn(comp, 'createForm').and.callThrough();
spyOn(comp, 'initFormValues').and.callThrough();
fixture.detectChanges();
});
describe('init', () => {
it('should init the comp', () => {
expect(comp).toBeTruthy();
});
it('should create the form and init the values based on an existing alert', () => {
expect(comp.createForm).toHaveBeenCalled();
expect(comp.initFormValues).toHaveBeenCalledWith(systemWideAlert);
});
});
describe('createForm', () => {
it('should create the form', () => {
const now = new Date();
comp.createForm();
expect(comp.formMessage.value).toEqual('');
expect(comp.formActive.value).toEqual(false);
expect(comp.time).toEqual({hour: now.getHours(), minute: now.getMinutes()});
expect(comp.date).toEqual({year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate()});
});
});
describe('initFormValues', () => {
it('should fill in the form based on the provided system-wide alert', () => {
comp.initFormValues(systemWideAlert);
const countDownTo = zonedTimeToUtc(systemWideAlert.countdownTo, 'UTC');
expect(comp.formMessage.value).toEqual(systemWideAlert.message);
expect(comp.formActive.value).toEqual(true);
expect(comp.time).toEqual({hour: countDownTo.getHours(), minute: countDownTo.getMinutes()});
expect(comp.date).toEqual({
year: countDownTo.getFullYear(),
month: countDownTo.getMonth() + 1,
day: countDownTo.getDate()
});
});
});
describe('setCounterEnabled', () => {
it('should set the preview time on enable and update the behaviour subject', () => {
spyOn(comp, 'updatePreviewTime');
comp.setCounterEnabled(true);
expect(comp.updatePreviewTime).toHaveBeenCalled();
expect(comp.counterEnabled$.value).toBeTrue();
});
it('should reset the preview time on disable and update the behaviour subject', () => {
spyOn(comp, 'updatePreviewTime');
comp.setCounterEnabled(false);
expect(comp.updatePreviewTime).not.toHaveBeenCalled();
expect(comp.previewDays).toEqual(0);
expect(comp.previewHours).toEqual(0);
expect(comp.previewMinutes).toEqual(0);
expect(comp.counterEnabled$.value).toBeFalse();
});
});
describe('updatePreviewTime', () => {
it('should calculate the difference between the current date and the date configured in the form', () => {
const countDownDate = new Date();
countDownDate.setDate(countDownDate.getDate() + 1);
countDownDate.setHours(countDownDate.getHours() + 1);
countDownDate.setMinutes(countDownDate.getMinutes() + 1);
comp.time = {hour: countDownDate.getHours(), minute: countDownDate.getMinutes()};
comp.date = {year: countDownDate.getFullYear(), month: countDownDate.getMonth() + 1, day: countDownDate.getDate()};
comp.updatePreviewTime();
expect(comp.previewDays).toEqual(1);
expect(comp.previewHours).toEqual(1);
expect(comp.previewDays).toEqual(1);
});
});
describe('setActive', () => {
it('should set whether the alert is active and save the current alert', () => {
spyOn(comp, 'save');
spyOn(comp.formActive, 'patchValue');
comp.setActive(true);
expect(comp.formActive.patchValue).toHaveBeenCalledWith(true);
expect(comp.save).toHaveBeenCalledWith(false);
});
});
describe('save', () => {
it('should update the exising alert with the form values and show a success notification on success and navigate back', () => {
spyOn(comp, 'back');
comp.currentAlert = systemWideAlert;
comp.formMessage.patchValue('New message');
comp.formActive.patchValue(true);
comp.time = {hour: 4, minute: 26};
comp.date = {year: 2023, month: 1, day: 25};
const expectedAlert = new SystemWideAlert();
expectedAlert.alertId = systemWideAlert.alertId;
expectedAlert.message = 'New message';
expectedAlert.active = true;
const countDownTo = new Date(2023, 0, 25, 4, 26);
expectedAlert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
comp.save();
expect(systemWideAlertDataService.put).toHaveBeenCalledWith(expectedAlert);
expect(notificationsService.success).toHaveBeenCalled();
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('systemwidealerts');
expect(comp.back).toHaveBeenCalled();
});
it('should update the exising alert with the form values and show a success notification on success and not navigate back when false is provided to the save method', () => {
spyOn(comp, 'back');
comp.currentAlert = systemWideAlert;
comp.formMessage.patchValue('New message');
comp.formActive.patchValue(true);
comp.time = {hour: 4, minute: 26};
comp.date = {year: 2023, month: 1, day: 25};
const expectedAlert = new SystemWideAlert();
expectedAlert.alertId = systemWideAlert.alertId;
expectedAlert.message = 'New message';
expectedAlert.active = true;
const countDownTo = new Date(2023, 0, 25, 4, 26);
expectedAlert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
comp.save(false);
expect(systemWideAlertDataService.put).toHaveBeenCalledWith(expectedAlert);
expect(notificationsService.success).toHaveBeenCalled();
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('systemwidealerts');
expect(comp.back).not.toHaveBeenCalled();
});
it('should update the exising alert with the form values but add an empty countdown date when disabled and show a success notification on success', () => {
spyOn(comp, 'back');
comp.currentAlert = systemWideAlert;
comp.formMessage.patchValue('New message');
comp.formActive.patchValue(true);
comp.time = {hour: 4, minute: 26};
comp.date = {year: 2023, month: 1, day: 25};
comp.counterEnabled$.next(false);
const expectedAlert = new SystemWideAlert();
expectedAlert.alertId = systemWideAlert.alertId;
expectedAlert.message = 'New message';
expectedAlert.active = true;
expectedAlert.countdownTo = null;
comp.save();
expect(systemWideAlertDataService.put).toHaveBeenCalledWith(expectedAlert);
expect(notificationsService.success).toHaveBeenCalled();
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('systemwidealerts');
expect(comp.back).toHaveBeenCalled();
});
it('should update the exising alert with the form values and show a error notification on error', () => {
spyOn(comp, 'back');
(systemWideAlertDataService.put as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
comp.currentAlert = systemWideAlert;
comp.formMessage.patchValue('New message');
comp.formActive.patchValue(true);
comp.time = {hour: 4, minute: 26};
comp.date = {year: 2023, month: 1, day: 25};
const expectedAlert = new SystemWideAlert();
expectedAlert.alertId = systemWideAlert.alertId;
expectedAlert.message = 'New message';
expectedAlert.active = true;
const countDownTo = new Date(2023, 0, 25, 4, 26);
expectedAlert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
comp.save();
expect(systemWideAlertDataService.put).toHaveBeenCalledWith(expectedAlert);
expect(notificationsService.error).toHaveBeenCalled();
expect(requestService.setStaleByHrefSubstring).not.toHaveBeenCalledWith('systemwidealerts');
expect(comp.back).not.toHaveBeenCalled();
});
it('should create a new alert with the form values and show a success notification on success', () => {
spyOn(comp, 'back');
comp.currentAlert = undefined;
comp.formMessage.patchValue('New message');
comp.formActive.patchValue(true);
comp.time = {hour: 4, minute: 26};
comp.date = {year: 2023, month: 1, day: 25};
const expectedAlert = new SystemWideAlert();
expectedAlert.message = 'New message';
expectedAlert.active = true;
const countDownTo = new Date(2023, 0, 25, 4, 26);
expectedAlert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
comp.save();
expect(systemWideAlertDataService.create).toHaveBeenCalledWith(expectedAlert);
expect(notificationsService.success).toHaveBeenCalled();
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('systemwidealerts');
expect(comp.back).toHaveBeenCalled();
});
it('should create a new alert with the form values and show a error notification on error', () => {
spyOn(comp, 'back');
(systemWideAlertDataService.create as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
comp.currentAlert = undefined;
comp.formMessage.patchValue('New message');
comp.formActive.patchValue(true);
comp.time = {hour: 4, minute: 26};
comp.date = {year: 2023, month: 1, day: 25};
const expectedAlert = new SystemWideAlert();
expectedAlert.message = 'New message';
expectedAlert.active = true;
const countDownTo = new Date(2023, 0, 25, 4, 26);
expectedAlert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
comp.save();
expect(systemWideAlertDataService.create).toHaveBeenCalledWith(expectedAlert);
expect(notificationsService.error).toHaveBeenCalled();
expect(requestService.setStaleByHrefSubstring).not.toHaveBeenCalledWith('systemwidealerts');
expect(comp.back).not.toHaveBeenCalled();
});
});
describe('back', () => {
it('should navigate back to the home page', () => {
comp.back();
expect(router.navigate).toHaveBeenCalledWith(['/home']);
});
});
});

View File

@@ -0,0 +1,254 @@
import { Component, OnInit } from '@angular/core';
import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { filter, map } from 'rxjs/operators';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { SystemWideAlert } from '../system-wide-alert.model';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { BehaviorSubject, Observable } from 'rxjs';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
import { RemoteData } from '../../core/data/remote-data';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { Router } from '@angular/router';
import { RequestService } from '../../core/data/request.service';
import { TranslateService } from '@ngx-translate/core';
/**
* Component responsible for rendering the form to update a system-wide alert
*/
@Component({
selector: 'ds-system-wide-alert-form',
styleUrls: ['./system-wide-alert-form.component.scss'],
templateUrl: './system-wide-alert-form.component.html'
})
export class SystemWideAlertFormComponent implements OnInit {
/**
* Observable to track an existing system-wide alert
*/
systemWideAlert$: Observable<SystemWideAlert>;
/**
* The currently configured system-wide alert
*/
currentAlert: SystemWideAlert;
/**
* The form group representing the system-wide alert
*/
alertForm: FormGroup;
/**
* Date object to store the countdown date part
*/
date: NgbDateStruct;
/**
* The minimum date for the countdown timer
*/
minDate: NgbDateStruct;
/**
* Object to store the countdown time part
*/
time;
/**
* Behaviour subject to track whether the counter is enabled
*/
counterEnabled$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/**
* The amount of minutes to be used in the banner preview
*/
previewMinutes: number;
/**
* The amount of hours to be used in the banner preview
*/
previewHours: number;
/**
* The amount of days to be used in the banner preview
*/
previewDays: number;
constructor(
protected systemWideAlertDataService: SystemWideAlertDataService,
protected notificationsService: NotificationsService,
protected router: Router,
protected requestService: RequestService,
protected translateService: TranslateService
) {
}
ngOnInit() {
this.systemWideAlert$ = this.systemWideAlertDataService.findAll().pipe(
getFirstCompletedRemoteData(),
map((rd) => {
if (rd.hasSucceeded) {
return rd.payload;
} else {
this.notificationsService.error('system-wide-alert-form.retrieval.error');
}
}),
map((payload: PaginatedList<SystemWideAlert>) => payload.page),
filter((page) => isNotEmpty(page)),
map((page) => page[0])
);
this.createForm();
const currentDate = new Date();
this.minDate = {year: currentDate.getFullYear(), month: currentDate.getMonth() + 1, day: currentDate.getDate()};
this.systemWideAlert$.subscribe((alert) => {
this.currentAlert = alert;
this.initFormValues(alert);
});
}
/**
* Creates the form with empty values
*/
createForm() {
this.alertForm = new FormBuilder().group({
formMessage: new FormControl('', {
validators: [Validators.required],
}),
formActive: new FormControl(false),
}
);
this.setDateTime(new Date());
}
/**
* Sets the form values based on the values retrieve from the provided system-wide alert
* @param alert - System-wide alert to use to init the form
*/
initFormValues(alert: SystemWideAlert) {
this.formMessage.patchValue(alert.message);
this.formActive.patchValue(alert.active);
const countDownTo = zonedTimeToUtc(alert.countdownTo, 'UTC');
if (countDownTo.getTime() - new Date().getTime() > 0) {
this.counterEnabled$.next(true);
this.setDateTime(countDownTo);
}
}
/**
* Set whether the system-wide alert is active
* Will also save the info in the current system-wide alert
* @param active
*/
setActive(active: boolean) {
this.formActive.patchValue(active);
this.save(false);
}
/**
* Set whether the countdown timer is enabled or disabled. This will also update the counter in the preview
* @param enabled - Whether the countdown timer is enabled or disabled.
*/
setCounterEnabled(enabled: boolean) {
this.counterEnabled$.next(enabled);
if (!enabled) {
this.previewMinutes = 0;
this.previewHours = 0;
this.previewDays = 0;
} else {
this.updatePreviewTime();
}
}
private setDateTime(dateToSet) {
this.time = {hour: dateToSet.getHours(), minute: dateToSet.getMinutes()};
this.date = {year: dateToSet.getFullYear(), month: dateToSet.getMonth() + 1, day: dateToSet.getDate()};
this.updatePreviewTime();
}
/**
* Update the preview time based on the configured countdown date and the current time
*/
updatePreviewTime() {
const countDownTo = new Date(this.date.year, this.date.month - 1, this.date.day, this.time.hour, this.time.minute);
const timeDifference = countDownTo.getTime() - new Date().getTime();
this.allocateTimeUnits(timeDifference);
}
/**
* Helper method to push how many days, hours and minutes are left in the countdown to their respective behaviour subject
* @param timeDifference - The time difference to calculate and push the time units for
*/
private allocateTimeUnits(timeDifference) {
this.previewMinutes = Math.floor((timeDifference) / (1000 * 60) % 60);
this.previewHours = Math.floor((timeDifference) / (1000 * 60 * 60) % 24);
this.previewDays = Math.floor((timeDifference) / (1000 * 60 * 60 * 24));
}
get formMessage() {
return this.alertForm.get('formMessage');
}
get formActive() {
return this.alertForm.get('formActive');
}
/**
* Save the system-wide alert present in the form
* When no alert is present yet on the server, a new one will be created
* When one already exists, the existing one will be updated
*
* @param navigateToHomePage - Whether the user should be navigated back on successful save or not
*/
save(navigateToHomePage = true) {
const alert = new SystemWideAlert();
alert.message = this.formMessage.value;
alert.active = this.formActive.value;
if (this.counterEnabled$.getValue()) {
const countDownTo = new Date(this.date.year, this.date.month - 1, this.date.day, this.time.hour, this.time.minute);
alert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
} else {
alert.countdownTo = null;
}
if (hasValue(this.currentAlert)) {
const updatedAlert = Object.assign(new SystemWideAlert(), this.currentAlert, alert);
this.handleResponse(this.systemWideAlertDataService.put(updatedAlert), 'system-wide-alert.form.update', navigateToHomePage);
} else {
this.handleResponse(this.systemWideAlertDataService.create(alert), 'system-wide-alert.form.create', navigateToHomePage);
}
}
private handleResponse(response$: Observable<RemoteData<SystemWideAlert>>, messagePrefix, navigateToHomePage: boolean) {
response$.pipe(
getFirstCompletedRemoteData()
).subscribe((response: RemoteData<SystemWideAlert>) => {
if (response.hasSucceeded) {
this.notificationsService.success(this.translateService.get(`${messagePrefix}.success`));
this.requestService.setStaleByHrefSubstring('systemwidealerts');
if (navigateToHomePage) {
this.back();
}
} else {
this.notificationsService.error(this.translateService.get(`${messagePrefix}.error`, response.errorMessage));
}
});
}
/**
* Navigate back to the homepage
*/
back() {
this.router.navigate(['/home']);
}
}

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import {
SiteAdministratorGuard
} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { SystemWideAlertFormComponent } from './alert-form/system-wide-alert-form.component';
@NgModule({
imports: [
RouterModule.forChild([
{
path: '',
canActivate: [SiteAdministratorGuard],
component: SystemWideAlertFormComponent,
},
])
]
})
export class SystemWideAlertRoutingModule {
}

View File

@@ -0,0 +1,55 @@
import { autoserialize, deserialize } from 'cerialize';
import { typedObject } from '../core/cache/builders/build-decorators';
import { CacheableObject } from '../core/cache/cacheable-object.model';
import { HALLink } from '../core/shared/hal-link.model';
import { ResourceType } from '../core/shared/resource-type';
import { excludeFromEquals } from '../core/utilities/equals.decorators';
import { SYSTEMWIDEALERT } from './system-wide-alert.resource-type';
/**
* Object representing a system-wide alert
*/
@typedObject
export class SystemWideAlert implements CacheableObject {
static type = SYSTEMWIDEALERT;
/**
* The object type
*/
@excludeFromEquals
@autoserialize
type: ResourceType;
/**
* The identifier for this system-wide alert
*/
@autoserialize
alertId: string;
/**
* The message for this system-wide alert
*/
@autoserialize
message: string;
/**
* A string representation of the date to which this system-wide alert will count down when active
*/
@autoserialize
countdownTo: string;
/**
* Whether the system-wide alert is active
*/
@autoserialize
active: boolean;
/**
* The {@link HALLink}s for this system-wide alert
*/
@deserialize
_links: {
self: HALLink,
};
}

View File

@@ -0,0 +1,33 @@
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SystemWideAlertBannerComponent } from './alert-banner/system-wide-alert-banner.component';
import { SystemWideAlertFormComponent } from './alert-form/system-wide-alert-form.component';
import { SharedModule } from '../shared/shared.module';
import { SystemWideAlertDataService } from '../core/data/system-wide-alert-data.service';
import { SystemWideAlertRoutingModule } from './system-wide-alert-routing.module';
import { UiSwitchModule } from 'ngx-ui-switch';
import { NgbDatepickerModule, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap';
@NgModule({
imports: [
FormsModule,
SharedModule,
UiSwitchModule,
SystemWideAlertRoutingModule,
NgbTimepickerModule,
NgbDatepickerModule,
],
exports: [
SystemWideAlertBannerComponent
],
declarations: [
SystemWideAlertBannerComponent,
SystemWideAlertFormComponent
],
providers: [
SystemWideAlertDataService
]
})
export class SystemWideAlertModule {
}

View File

@@ -0,0 +1,10 @@
/**
* The resource type for SystemWideAlert
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
import { ResourceType } from '../core/shared/resource-type';
export const SYSTEMWIDEALERT = new ResourceType('systemwidealert');

View File

@@ -4922,4 +4922,53 @@
"home.recent-submissions.head": "Recent Submissions", "home.recent-submissions.head": "Recent Submissions",
"listable-notification-object.default-message": "This object couldn't be retrieved", "listable-notification-object.default-message": "This object couldn't be retrieved",
"system-wide-alert-banner.retrieval.error": "Something went wrong retrieving the system-wide alert banner",
"system-wide-alert-banner.countdown.prefix": "In",
"system-wide-alert-banner.countdown.days": "{{days}} day(s),",
"system-wide-alert-banner.countdown.hours": "{{hours}} hour(s) and",
"system-wide-alert-banner.countdown.minutes": "{{minutes}} minute(s):",
"menu.section.system-wide-alert": "System-wide Alert",
"system-wide-alert.form.header": "System-wide Alert",
"system-wide-alert-form.retrieval.error": "Something went wrong retrieving the system-wide alert",
"system-wide-alert.form.cancel": "Cancel",
"system-wide-alert.form.save": "Save",
"system-wide-alert.form.label.active": "ACTIVE",
"system-wide-alert.form.label.inactive": "INACTIVE",
"system-wide-alert.form.error.message": "The system wide alert must have a message",
"system-wide-alert.form.label.message": "Alert message",
"system-wide-alert.form.label.countdownTo.enable": "Enable a countdown timer",
"system-wide-alert.form.label.countdownTo.hint": "Hint: Set a countdown timer. When enabled, a date can be set in the future and the system-wide alert banner will perform a countdown to the set date. When this timer ends, it will disappear from the alert. The server will NOT be automatically stopped.",
"system-wide-alert.form.label.preview": "System-wide alert preview",
"system-wide-alert.form.update.success": "The system-wide alert was successfully updated",
"system-wide-alert.form.update.error": "Something went wrong when updating the system-wide alert",
"system-wide-alert.form.create.success": "The system-wide alert was successfully created",
"system-wide-alert.form.create.error": "Something went wrong when creating the system-wide alert",
"admin.system-wide-alert.breadcrumbs": "System-wide Alerts",
"admin.system-wide-alert.title": "System-wide Alerts",
} }

View File

@@ -123,6 +123,7 @@ import { ItemSharedModule } from '../../app/item-page/item-shared.module';
import { ResultsBackButtonComponent } from './app/shared/results-back-button/results-back-button.component'; import { ResultsBackButtonComponent } from './app/shared/results-back-button/results-back-button.component';
import { DsoEditMetadataComponent } from './app/dso-shared/dso-edit-metadata/dso-edit-metadata.component'; import { DsoEditMetadataComponent } from './app/dso-shared/dso-edit-metadata/dso-edit-metadata.component';
import { DsoSharedModule } from '../../app/dso-shared/dso-shared.module'; import { DsoSharedModule } from '../../app/dso-shared/dso-shared.module';
import { SystemWideAlertModule } from '../../app/system-wide-alert/system-wide-alert.module';
const DECLARATIONS = [ const DECLARATIONS = [
FileSectionComponent, FileSectionComponent,
@@ -234,6 +235,7 @@ const DECLARATIONS = [
ResourcePoliciesModule, ResourcePoliciesModule,
ComcolModule, ComcolModule,
DsoSharedModule, DsoSharedModule,
SystemWideAlertModule
], ],
declarations: DECLARATIONS, declarations: DECLARATIONS,
exports: [ exports: [