From f4751c036172894b18661acf1df71b4a0505684e Mon Sep 17 00:00:00 2001 From: ievavold <ievavold@wisc.edu> Date: Wed, 21 Aug 2019 09:58:04 -0500 Subject: [PATCH] ROENROLL-1918 show spinner while audit in progress --- .../dars-audit-view.component.ts | 20 ++-- .../dars/dars-view/dars-view.component.html | 6 +- src/app/dars/dars-view/dars-view.component.ts | 70 +++++++++++--- .../metadata-table.component.html | 27 +++--- .../metadata-table.component.scss | 5 +- .../metadata-table.component.ts | 74 +++++++------- src/app/dars/models/audit-metadata.ts | 10 +- src/app/dars/services/api.service.ts | 8 +- src/app/dars/store/actions.ts | 78 ++++++++------- src/app/dars/store/effects.ts | 94 +++++++++--------- src/app/dars/store/reducer.ts | 96 +++++++++++++++---- src/app/dars/store/selectors.ts | 38 +++++++- src/app/dars/store/state.ts | 11 ++- src/app/dars/store/utils.ts | 53 ++++++++++ 14 files changed, 397 insertions(+), 193 deletions(-) create mode 100644 src/app/dars/store/utils.ts diff --git a/src/app/dars/dars-audit-view/dars-audit-view.component.ts b/src/app/dars/dars-audit-view/dars-audit-view.component.ts index e57f115..96995a6 100644 --- a/src/app/dars/dars-audit-view/dars-audit-view.component.ts +++ b/src/app/dars/dars-audit-view/dars-audit-view.component.ts @@ -12,8 +12,11 @@ import { AuditStatus, MetadataStatus } from '../store/state'; import { Store } from '@ngrx/store'; import { GlobalState } from '@app/core/state'; import * as selectors from '../store/selectors'; -import { StartLoadingAudit, StartLoadingMetadata } from '../store/actions'; -import { AuditMetadata } from '../models/audit-metadata'; +import { StartLoadingAudit, StartLoadingDARSView } from '../store/actions'; +import { + AuditMetadata, + AuditMetadataWithReportId, +} from '../models/audit-metadata'; @Component({ selector: 'cse-dars-audit-view', @@ -23,7 +26,7 @@ import { AuditMetadata } from '../models/audit-metadata'; export class AuditViewComponent implements OnInit { public metadataStatus$: Observable<MetadataStatus['status']>; public darsDegreeAuditReportId$: Observable<number>; - public metadata$: Observable<AuditMetadata>; + public metadata$: Observable<AuditMetadataWithReportId>; public audit$: Observable<AuditStatus>; constructor( @@ -44,7 +47,7 @@ export class AuditViewComponent implements OnInit { this.metadataStatus$.subscribe(metadataStatus => { if (metadataStatus === 'NotLoaded') { - this.store.dispatch(new StartLoadingMetadata()); + this.store.dispatch(new StartLoadingDARSView()); } }); @@ -65,11 +68,12 @@ export class AuditViewComponent implements OnInit { this.store.select(selectors.whatIfMetadata), ]).pipe( map(([darsDegreeAuditReportId, programMetadata, whatIfMetadata]) => { - return programMetadata.concat(whatIfMetadata).find(md => { - return md.darsDegreeAuditReportId === darsDegreeAuditReportId; - }); + return ( + programMetadata[darsDegreeAuditReportId] || + whatIfMetadata[darsDegreeAuditReportId] + ); }), - filter((metadata): metadata is AuditMetadata => !!metadata), + filter((metadata): metadata is AuditMetadataWithReportId => !!metadata), shareReplay(), ); diff --git a/src/app/dars/dars-view/dars-view.component.html b/src/app/dars/dars-view/dars-view.component.html index 22fa601..69390e8 100644 --- a/src/app/dars/dars-view/dars-view.component.html +++ b/src/app/dars/dars-view/dars-view.component.html @@ -28,8 +28,9 @@ <div class="dars-audit-group" > <cse-dars-metadata-table + [waiting]="programWaiting$" [metadata]="programMetadata$" - [type]="'normal'" + [type]="'program'" [title]="'Degree Audit'" [tagline]="'See the progress towards your current academic plan/program and degree plans.'" [button]="'Run new degree audit'" @@ -39,8 +40,9 @@ <div class="dars-audit-group"> <cse-dars-metadata-table + [waiting]="whatIfWaiting$" [metadata]="whatIfMetadata$" - [type]="'what-if'" + [type]="'whatIf'" [title]="'‘What if’ Audit'" [tagline]="'See the progress towards a new academic plan/program and degree plans.'" [button]="'Run new ‘what if’ audit'" diff --git a/src/app/dars/dars-view/dars-view.component.ts b/src/app/dars/dars-view/dars-view.component.ts index cfbf577..2ae6026 100644 --- a/src/app/dars/dars-view/dars-view.component.ts +++ b/src/app/dars/dars-view/dars-view.component.ts @@ -1,13 +1,13 @@ import { StartSendingAudit } from './../store/actions'; import { MediaMatcher } from '@angular/cdk/layout'; -import { Component, OnInit } from '@angular/core'; -import { AuditMetadata } from '../models/audit-metadata'; -import { MatDialog } from '@angular/material'; +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { AuditMetadata, AuditMetadataMap } from '../models/audit-metadata'; +import { MatDialog, MatSnackBar } from '@angular/material'; import { DARSState } from '../store/state'; import { Store } from '@ngrx/store'; import { GlobalState } from '@app/core/state'; import * as selectors from '../store/selectors'; -import { Observable } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import * as darsActions from '../store/actions'; import { Alert } from '@app/core/models/alert'; import { @@ -18,40 +18,82 @@ import { NewWhatIfAuditDialogComponent, NewWhatIfAuditFields, } from '../new-what-if-audit-dialog/new-what-if-audit-dialog.component'; -import { shareReplay } from 'rxjs/operators'; +import { shareReplay, distinctUntilChanged, map } from 'rxjs/operators'; +import { WebsocketService } from '@app/shared/services/websocket.service'; + +const flattenAndSortMetadataMap = (metadataMap: AuditMetadataMap) => { + return (Object.values(metadataMap) as AuditMetadata[]).sort((a, b) => { + if (a.darsAuditRunDate > b.darsAuditRunDate) { + return 1; + } else if (a.darsAuditRunDate < b.darsAuditRunDate) { + return -1; + } else { + return 0; + } + }); +}; @Component({ selector: 'cse-dars-view', templateUrl: './dars-view.component.html', styleUrls: ['./dars-view.component.scss'], }) -export class DARSViewComponent implements OnInit { +export class DARSViewComponent implements OnInit, OnDestroy { public metadataStatus$: Observable<DARSState['metadata']['status']>; + public programWaiting$: Observable<{ outstanding: number; pending: number }>; public programMetadata$: Observable<AuditMetadata[]>; + public whatIfWaiting$: Observable<{ outstanding: number; pending: number }>; public whatIfMetadata$: Observable<AuditMetadata[]>; public mobileView: MediaQueryList; public alerts$: Observable<Alert[]>; + public messageSub: Subscription; constructor( private store: Store<GlobalState>, public dialog: MatDialog, public mediaMatcher: MediaMatcher, + websocketService: WebsocketService, + private snackBar: MatSnackBar, ) { this.mobileView = mediaMatcher.matchMedia('(max-width: 959px)'); + this.messageSub = websocketService.messages.subscribe(message => { + const newMessage = JSON.parse(message); + if (newMessage && newMessage.type === 'auditStatus') { + this.store.dispatch( + new darsActions.RefreshMetadata({ + callback: () => this.snackBar.open('Audit Complete'), + }), + ); + } + }); } public ngOnInit() { - this.store.dispatch(new darsActions.StartLoadingMetadata()); + this.store.dispatch(new darsActions.StartLoadingDARSView()); this.metadataStatus$ = this.store.select(selectors.metadataStatus); - this.programMetadata$ = this.store - .select(selectors.programMetadata) - .pipe(shareReplay()); - this.whatIfMetadata$ = this.store - .select(selectors.whatIfMetadata) - .pipe(shareReplay()); + this.programWaiting$ = this.store.select( + selectors.outstandingAndPendingPrograms, + ); + this.programMetadata$ = this.store.select(selectors.programMetadata).pipe( + distinctUntilChanged(), + map(flattenAndSortMetadataMap), + shareReplay(), + ); + this.whatIfWaiting$ = this.store.select( + selectors.outstandingAndPendingWhatIf, + ); + this.whatIfMetadata$ = this.store.select(selectors.whatIfMetadata).pipe( + distinctUntilChanged(), + map(flattenAndSortMetadataMap), + shareReplay(), + ); this.alerts$ = this.store.select(selectors.alerts); } + public ngOnDestroy() { + this.messageSub.unsubscribe(); + } + public onDismissAlert(key: string) { this.store.dispatch(new darsActions.DismissAlert({ key })); } @@ -64,6 +106,7 @@ export class DARSViewComponent implements OnInit { if (event) { return this.store.dispatch( new StartSendingAudit({ + auditType: 'program', darsInstitutionCode: event.darsInstitutionCode, darsDegreeProgramCode: event.darsDegreeProgramCode, degreePlannerPlanName: event.degreePlannerPlanName, @@ -82,6 +125,7 @@ export class DARSViewComponent implements OnInit { if (event) { return this.store.dispatch( new StartSendingAudit({ + auditType: 'whatIf', darsInstitutionCode: event.darsInstitutionCode, darsDegreeProgramCode: event.darsDegreeProgramCode, degreePlannerPlanName: event.degreePlannerPlanName, diff --git a/src/app/dars/metadata-table/metadata-table.component.html b/src/app/dars/metadata-table/metadata-table.component.html index d481d30..ec54d65 100644 --- a/src/app/dars/metadata-table/metadata-table.component.html +++ b/src/app/dars/metadata-table/metadata-table.component.html @@ -11,11 +11,11 @@ <div *ngIf="(metadata | async).length <= 0" class="no-audits"> <ng-container [ngSwitch]="type"> - <ng-container *ngSwitchCase="'normal'"> + <ng-container *ngSwitchCase="'program'"> <p>Stay on top of your required credits and courses by running a degree audit that compares your academic record with your declared program requirements.</p> </ng-container> - <ng-container *ngSwitchCase="'what-if'"> + <ng-container *ngSwitchCase="'whatIf'"> <p>Still deciding on a program of study? A What-If audit can help you determine your academic progress towards any degree.</p> </ng-container> @@ -57,7 +57,7 @@ <div>Run {{audit.darsAuditRunDate | date:'M/d/yy \'at\' h:mm a'}}</div> </div> <ng-template #loading> - <p class="in-progress">Audit in progress <mat-progress-spinner mode="indeterminate" diameter="24"></mat-progress-spinner></p> + <p class="in-progress">Audit in progress</p> </ng-template> </td> </ng-container> @@ -66,14 +66,19 @@ <ng-container matColumnDef="actions"> <th mat-header-cell *matHeaderCellDef scope="col">Actions</th> <td mat-cell *matCellDef="let audit"> - <!-- TODO: While an audit is in progress the route is null which causes an error '|| 0' is a working fix --> - <a class="mat-stroked-button mat-primary view-audit-btn" attr.aria-label="View full audit report for {{(audit.darsDegreeProgramDescription || '').toLowerCase()}} Program of Study" [routerLink]="['/dars', audit.darsDegreeAuditReportId || 0]">View</a> - <a mat-stroked-button color="primary" class="download-audit-pdf-btn" href="/api/darspdfservice?reportId={{ audit?.darsDegreeAuditReportId }}" target="_blank"> - <mat-icon - matTooltip="Download PDF" - matTooltipPosition="right">vertical_align_bottom</mat-icon> - <span class="cdk-visually-hidden">Download Audit PDF for {{(audit.darsDegreeProgramDescription || '').toLowerCase()}} Program of Study</span> - </a> + <ng-container *ngIf="audit.darsDegreeAuditReportId; else loading"> + <!-- TODO: While an audit is in progress the route is null which causes an error '|| 0' is a working fix --> + <a class="mat-stroked-button mat-primary view-audit-btn" attr.aria-label="View full audit report for {{(audit.darsDegreeProgramDescription || '').toLowerCase()}} Program of Study" [routerLink]="['/dars', audit.darsDegreeAuditReportId || 0]">View</a> + <a mat-stroked-button color="primary" class="download-audit-pdf-btn" href="/api/darspdfservice?reportId={{ audit?.darsDegreeAuditReportId }}" target="_blank"> + <mat-icon + matTooltip="Download PDF" + matTooltipPosition="right">vertical_align_bottom</mat-icon> + <span class="cdk-visually-hidden">Download Audit PDF for {{(audit.darsDegreeProgramDescription || '').toLowerCase()}} Program of Study</span> + </a> + </ng-container> + <ng-template #loading> + <mat-progress-spinner mode="indeterminate" diameter="24"></mat-progress-spinner> + </ng-template> </td> </ng-container> diff --git a/src/app/dars/metadata-table/metadata-table.component.scss b/src/app/dars/metadata-table/metadata-table.component.scss index e9ae13f..66dadd6 100644 --- a/src/app/dars/metadata-table/metadata-table.component.scss +++ b/src/app/dars/metadata-table/metadata-table.component.scss @@ -45,12 +45,9 @@ $black: #000000; } } .in-progress { + margin-bottom: 0; color: mat-color($my-app-primary); font-weight: 500; - mat-progress-spinner { - display: inline-block; - top: 7px; - } } .mat-paginator { diff --git a/src/app/dars/metadata-table/metadata-table.component.ts b/src/app/dars/metadata-table/metadata-table.component.ts index c69e009..7b1fb28 100644 --- a/src/app/dars/metadata-table/metadata-table.component.ts +++ b/src/app/dars/metadata-table/metadata-table.component.ts @@ -1,7 +1,3 @@ -import { StartLoadingMetadata } from './../store/actions'; -import { Store } from '@ngrx/store'; -import { GlobalState } from '@app/core/state'; -import { WebsocketService } from './../../shared/services/websocket.service'; import { Component, Input, @@ -9,20 +5,20 @@ import { EventEmitter, ViewChild, OnInit, - OnDestroy, } from '@angular/core'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { AuditMetadata } from '../models/audit-metadata'; import { MatPaginator } from '@angular/material/paginator'; import { MatTableDataSource } from '@angular/material/table'; -import { Observable, Subscription } from 'rxjs'; +import { Observable, combineLatest } from 'rxjs'; import { PageEvent } from '@angular/material/paginator'; -export class AuditStatusMessage { - status: string; - jobId: string; - netId: string; - type: string; +interface MinimumAuditMetadata { + darsAuditRunDate: AuditMetadata['darsAuditRunDate']; + darsDegreeAuditReportId: AuditMetadata['darsDegreeAuditReportId']; + darsHonorsOptionDescription: AuditMetadata['darsHonorsOptionDescription']; + darsInstitutionCodeDescription: AuditMetadata['darsInstitutionCodeDescription']; + darsStatusOfDegreeAuditRequest: AuditMetadata['darsStatusOfDegreeAuditRequest']; + degreePlannerPlanName: AuditMetadata['degreePlannerPlanName']; } @Component({ @@ -30,9 +26,10 @@ export class AuditStatusMessage { templateUrl: './metadata-table.component.html', styleUrls: ['./metadata-table.component.scss'], }) -export class DarsMetadataTableComponent implements OnInit, OnDestroy { +export class DarsMetadataTableComponent implements OnInit { + @Input() public waiting: Observable<{ outstanding: number; pending: number }>; @Input() public metadata: Observable<AuditMetadata[]>; - @Input() public type: string; + @Input() public type: 'program' | 'whatIf'; @Input() public title: string; @Input() public tagline: string; @Input() public button: string; @@ -42,9 +39,7 @@ export class DarsMetadataTableComponent implements OnInit, OnDestroy { @ViewChild(MatPaginator) paginator: MatPaginator; public pageEvent: PageEvent; - public dataSource: MatTableDataSource<AuditMetadata>; - public messageSub: Subscription; - public newMessage: AuditStatusMessage; + public dataSource: MatTableDataSource<MinimumAuditMetadata>; public displayedColumns = [ 'school', 'program', @@ -54,21 +49,7 @@ export class DarsMetadataTableComponent implements OnInit, OnDestroy { 'actions', ]; - constructor( - private websocketService: WebsocketService, - private store: Store<GlobalState>, - private snackBar: MatSnackBar, - ) { - this.messageSub = websocketService.messages.subscribe(message => { - this.newMessage = JSON.parse(message); - if (this.newMessage && this.newMessage.type === 'auditStatus') { - this.store.dispatch(new StartLoadingMetadata()); - this.snackBar.open('Audit Complete'); - } - }); - } - - public static sortMetadata(metadata: AuditMetadata[]): AuditMetadata[] { + public static sortMetadata(metadata: MinimumAuditMetadata[]) { return metadata.sort((a, b) => { const aDate = new Date(a.darsAuditRunDate); const bDate = new Date(b.darsAuditRunDate); @@ -84,14 +65,27 @@ export class DarsMetadataTableComponent implements OnInit, OnDestroy { } public ngOnInit() { - this.metadata.subscribe(metadata => { - const sorted = DarsMetadataTableComponent.sortMetadata(metadata); - this.dataSource = new MatTableDataSource<AuditMetadata>(sorted); - this.dataSource.paginator = this.paginator; - }); - } + combineLatest([this.waiting, this.metadata]).subscribe( + ([waiting, metadata]) => { + const sorted = DarsMetadataTableComponent.sortMetadata(metadata); + + const totalWaiting = waiting.outstanding + waiting.pending; + const synthetic = Array.from({ length: totalWaiting }).map(() => ({ + darsAuditRunDate: '', + darsDegreeAuditReportId: null, + darsHonorsOptionDescription: '', + darsInstitutionCodeDescription: '', + darsStatusOfDegreeAuditRequest: '', + degreePlannerPlanName: '', + })); + + this.dataSource = new MatTableDataSource<MinimumAuditMetadata>([ + ...synthetic, + ...sorted, + ]); - public ngOnDestroy() { - this.messageSub.unsubscribe(); + this.dataSource.paginator = this.paginator; + }, + ); } } diff --git a/src/app/dars/models/audit-metadata.ts b/src/app/dars/models/audit-metadata.ts index c49cca8..4c7b3db 100644 --- a/src/app/dars/models/audit-metadata.ts +++ b/src/app/dars/models/audit-metadata.ts @@ -9,8 +9,16 @@ export interface AuditMetadata { darsStatusOfDegreeAuditRequest: string; darsHonorsOptionCode: string; darsHonorsOptionDescription: string; - darsDegreeAuditReportId: number; + darsDegreeAuditReportId: number | null; darsCatalogYearTerm: string; whichEnrolledCoursesIncluded: string; degreePlannerPlanName: string; } + +export type AuditMetadataWithReportId = AuditMetadata & { + darsDegreeAuditReportId: number; +}; + +export interface AuditMetadataMap { + [darsDegreeAuditReportId: number]: AuditMetadataWithReportId; +} diff --git a/src/app/dars/services/api.service.ts b/src/app/dars/services/api.service.ts index 26a0736..0b384b7 100644 --- a/src/app/dars/services/api.service.ts +++ b/src/app/dars/services/api.service.ts @@ -7,8 +7,6 @@ import { StudentDegreeProgram } from '../models/student-degree-program'; import { environment } from './../../../environments/environment'; import { Audit } from '../models/audit/audit'; import { CourseBase } from '@app/core/models/course'; -import { delay } from 'rxjs/operators'; -const auditResponse: Audit = require('../../../assets/mock-data/audit-response.json'); const degreeProgramsResponse: DegreePrograms = require('../../../assets/mock-data/degreeprograms-response.json'); const HTTP_OPTIONS = { @@ -59,7 +57,7 @@ export class DarsApiService { */ public getAudits(): Observable<AuditMetadata[]> { const url = `${environment.apiDarsUrl}/auditmetadata`; - return this.http.get<AuditMetadata[]>(url, HTTP_OPTIONS).pipe(delay(5000)); + return this.http.get<AuditMetadata[]>(url, HTTP_OPTIONS); } /** @@ -78,9 +76,9 @@ export class DarsApiService { darsDegreeProgramCode: string, degreePlannerPlanName?: string, whichEnrolledCoursesIncluded?: string, - ): Observable<AuditMetadata> { + ): Observable<{ darsJobId: string }> { const url = `${environment.apiDarsUrl}/auditrequest`; - return this.http.post<AuditMetadata>( + return this.http.post<{ darsJobId: string }>( url, { darsInstitutionCode, diff --git a/src/app/dars/store/actions.ts b/src/app/dars/store/actions.ts index 1100c81..f269ce3 100644 --- a/src/app/dars/store/actions.ts +++ b/src/app/dars/store/actions.ts @@ -1,80 +1,89 @@ import { DARSState } from '@app/dars/store/state'; import { Action } from '@ngrx/store'; -import { AuditMetadata } from '../models/audit-metadata'; +import { + AuditMetadata, + AuditMetadataWithReportId, +} from '../models/audit-metadata'; import { Audit } from '../models/audit/audit'; -import { SingleAuditRequest } from '../models/single-audit-request'; export enum DarsActionTypes { - ErrorLoadingMetadata = '[DARS] Error Loading Metadata', - StartLoadingMetadata = '[DARS] Start Loading Metadata', - DoneLoadingMetadata = '[DARS] Done Loading Metadata', - AddAuditMetadata = '[DARS] Add Audit Metadata', + ErrorLoadingDARSView = '[DARS] Error Loading DARS View', + StartLoadingDARSView = '[DARS] Start Loading DARS View', + DoneLoadingDARSView = '[DARS] Done Loading DARS View', + + RefreshMetadata = '[DARS] Refresh Metadata', + DoneRefreshingMetadata = '[DARS] Done Refreshing Metadata', + ErrorRefreshingMetadata = '[DARS] Error Refreshing Metadata', ErrorLoadingAudit = '[DARS] Error Loading Audit', StartLoadingAudit = '[DARS] Start Loading Audit', DoneLoadingAudit = '[DARS] Done Loading Audit', - PopulateDarsState = '[DARS] Done Loading state', - ErrorSendingAudit = 'DARS Error Sending Audit', + ErrorSendingAudit = '[DARS] Error Sending Audit', StartSendingAudit = '[DARS] Start Sending Audit', DoneSendingAudit = '[DARS] Done Sending Audit', DismissAlert = '[DARS] Dismiss Alert', } -export class ErrorLoadingMetadata implements Action { - public readonly type = DarsActionTypes.ErrorLoadingMetadata; +export class ErrorLoadingDARSView implements Action { + public readonly type = DarsActionTypes.ErrorLoadingDARSView; constructor(public payload: { message: string }) {} } -export class StartLoadingMetadata implements Action { - public readonly type = DarsActionTypes.StartLoadingMetadata; +export class StartLoadingDARSView implements Action { + public readonly type = DarsActionTypes.StartLoadingDARSView; } -export class DoneLoadingMetadata implements Action { - public readonly type = DarsActionTypes.DoneLoadingMetadata; +export class DoneLoadingDARSView implements Action { + public readonly type = DarsActionTypes.DoneLoadingDARSView; constructor(public payload: DARSState) {} } -export class ErrorLoadingAudit implements Action { - public readonly type = DarsActionTypes.ErrorLoadingAudit; - constructor(public payload: { message: string }) {} +export class RefreshMetadata implements Action { + public readonly type = DarsActionTypes.RefreshMetadata; + constructor(public payload?: { callback: () => void }) {} } -export class AddAuditMetadata implements Action { - public readonly type = DarsActionTypes.AddAuditMetadata; - constructor( - public payload: { - programMetadata: AuditMetadata[]; - whatIfMetadata: AuditMetadata[]; - }, - ) {} +export class DoneRefreshingMetadata implements Action { + public readonly type = DarsActionTypes.DoneRefreshingMetadata; + constructor(public payload: AuditMetadata[]) {} } -export class PopulateDarsState implements Action { - public readonly type = DarsActionTypes.PopulateDarsState; - constructor(public payload: Partial<DARSState>) {} +export class ErrorRefreshingMetadata implements Action { + public readonly type = DarsActionTypes.ErrorRefreshingMetadata; + constructor(public payload: { message: string }) {} +} + +export class ErrorLoadingAudit implements Action { + public readonly type = DarsActionTypes.ErrorLoadingAudit; + constructor(public payload: { message: string }) {} } export class StartLoadingAudit implements Action { public readonly type = DarsActionTypes.StartLoadingAudit; - constructor(public payload: AuditMetadata) {} + constructor(public payload: AuditMetadataWithReportId) {} } export class DoneLoadingAudit implements Action { public readonly type = DarsActionTypes.DoneLoadingAudit; - constructor(public payload: { metadata: AuditMetadata; audit: Audit }) {} + constructor( + public payload: { metadata: AuditMetadataWithReportId; audit: Audit }, + ) {} } export class ErrorSendingAudit implements Action { public readonly type = DarsActionTypes.ErrorSendingAudit; - constructor(public payload: { message: string }) {} + constructor( + public payload: { auditType: 'program' | 'whatIf'; message: string }, + ) {} } export class StartSendingAudit implements Action { public readonly type = DarsActionTypes.StartSendingAudit; constructor( public payload: { + auditType: 'program' | 'whatIf'; darsInstitutionCode: string; darsDegreeProgramCode: string; degreePlannerPlanName?: string; @@ -86,12 +95,7 @@ export class StartSendingAudit implements Action { export class DoneSendingAudit implements Action { public readonly type = DarsActionTypes.DoneSendingAudit; constructor( - public payload: { - darsInstitutionCode: string; - darsDegreeProgramCode: string; - degreePlannerPlanName?: string; - whichEnrolledCoursesIncluded?: string; - }, + public payload: { auditType: 'program' | 'whatIf'; darsJobId: string }, ) {} } diff --git a/src/app/dars/store/effects.ts b/src/app/dars/store/effects.ts index 581c21f..f8d06be 100644 --- a/src/app/dars/store/effects.ts +++ b/src/app/dars/store/effects.ts @@ -6,22 +6,14 @@ import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { DarsActionTypes } from '@app/dars/store/actions'; import * as darsActions from '@app/dars/store/actions'; -import { flatMap, map, catchError, withLatestFrom } from 'rxjs/operators'; +import { flatMap, map, catchError, withLatestFrom, tap } from 'rxjs/operators'; import { DarsApiService } from '../services/api.service'; import { Alert, DarsDisclaimerAlert } from '@app/core/models/alert'; import { DegreePlannerApiService } from '@app/degree-planner/services/api.service'; -import { AuditMetadata } from '../models/audit-metadata'; -import { SingleAuditRequest } from '../models/single-audit-request'; -import { StudentDegreeProgram } from '../models/student-degree-program'; import { of } from 'rxjs'; import * as selectors from '@app/dars/store/selectors'; - -const isFromDegreePlan = (md: AuditMetadata, pg: StudentDegreeProgram) => { - return ( - md.darsDegreeProgramCode === pg.darsDegreeProgramCode && - md.darsInstitutionCode === pg.darsInstitutionCode - ); -}; +import { groupAuditMetadata } from './utils'; +import { MatSnackBar } from '@angular/material'; @Injectable() export class DARSEffects { @@ -30,11 +22,12 @@ export class DARSEffects { private api: DarsApiService, private degreeAPI: DegreePlannerApiService, private store$: Store<GlobalState>, + private snackBar: MatSnackBar, ) {} @Effect() load$ = this.actions$.pipe( - ofType(DarsActionTypes.StartLoadingMetadata), + ofType(DarsActionTypes.StartLoadingDARSView), flatMap(() => { return forkJoinWithKeys({ @@ -59,37 +52,47 @@ export class DARSEffects { ); } - const programMetadata: AuditMetadata[] = []; - const whatIfMetadata: AuditMetadata[] = []; - - metadata.forEach(md => { - if (degreePrograms.some(dp => isFromDegreePlan(md, dp))) { - programMetadata.push(md); - } else { - whatIfMetadata.push(md); - } - }); - - return new darsActions.PopulateDarsState({ + return new darsActions.DoneLoadingDARSView({ degreePlans, + degreePrograms, metadata: { status: 'Loaded', - programMetadata, - whatIfMetadata, + outstanding: { program: 0, whatIf: 0 }, + pending: { program: 0, whatIf: 0 }, + ...groupAuditMetadata(metadata, degreePrograms), }, + audits: {}, alerts, }); }), catchError(() => { return of( - new darsActions.ErrorLoadingMetadata({ - message: 'Error loading metadata. Please try again', + new darsActions.ErrorLoadingDARSView({ + message: 'Error loading DARS information. Please try again', }), ); }), ); + @Effect() + refreshMetadata$ = this.actions$.pipe( + ofType<darsActions.RefreshMetadata>(DarsActionTypes.RefreshMetadata), + flatMap(action => + this.api.getAudits().pipe( + map(metadata => new darsActions.DoneRefreshingMetadata(metadata)), + tap(() => action.payload && action.payload.callback()), + catchError(() => { + return of( + new darsActions.ErrorRefreshingMetadata({ + message: 'Error loading metadata. Please try again', + }), + ); + }), + ), + ), + ); + @Effect() getAudit$ = this.actions$.pipe( ofType<darsActions.StartLoadingAudit>(DarsActionTypes.StartLoadingAudit), @@ -117,35 +120,34 @@ export class DARSEffects { newAudit$ = this.actions$.pipe( ofType(DarsActionTypes.StartSendingAudit), flatMap((action: darsActions.StartSendingAudit) => { - const metadata = action.payload; + const auditType = action.payload.auditType; return this.api .newAudit( - metadata.darsInstitutionCode, - metadata.darsDegreeProgramCode, - metadata.degreePlannerPlanName, - metadata.whichEnrolledCoursesIncluded, + action.payload.darsInstitutionCode, + action.payload.darsDegreeProgramCode, + action.payload.degreePlannerPlanName, + action.payload.whichEnrolledCoursesIncluded, ) .pipe( - map(audit => { - return new darsActions.DoneSendingAudit(action.payload); + map(({ darsJobId }) => { + return new darsActions.DoneSendingAudit({ auditType, darsJobId }); + }), + catchError(_err => { + this.snackBar.open('Unable to generate audit'); + return of( + new darsActions.ErrorSendingAudit({ + auditType, + message: 'Unable to generate audit', + }), + ); }), ); }), - - catchError(error => { - return of( - new darsActions.ErrorSendingAudit({ - message: 'Unable to add course', - }), - ); - }), ); @Effect() updateAudits$ = this.actions$.pipe( ofType(DarsActionTypes.DoneSendingAudit), - map(() => { - return new darsActions.StartLoadingMetadata(); - }), + map(() => new darsActions.RefreshMetadata()), ); } diff --git a/src/app/dars/store/reducer.ts b/src/app/dars/store/reducer.ts index 7889329..b29e60c 100644 --- a/src/app/dars/store/reducer.ts +++ b/src/app/dars/store/reducer.ts @@ -1,11 +1,15 @@ import { DARSState, INITIAL_DARS_STATE } from '@app/dars/store/state'; -import { DarsActionTypes, AddAuditMetadata } from './actions'; +import { DarsActionTypes } from './actions'; import * as darsActions from './actions'; +import { groupAuditMetadata } from './utils'; type SupportedActions = - | darsActions.ErrorLoadingMetadata - | darsActions.AddAuditMetadata - | darsActions.PopulateDarsState + | darsActions.DoneLoadingDARSView + | darsActions.ErrorLoadingDARSView + | darsActions.DoneRefreshingMetadata + | darsActions.StartSendingAudit + | darsActions.DoneSendingAudit + | darsActions.ErrorSendingAudit | darsActions.StartLoadingAudit | darsActions.DoneLoadingAudit | darsActions.DismissAlert; @@ -15,35 +19,85 @@ export function darsReducer( action: SupportedActions, ): DARSState { switch (action.type) { - case DarsActionTypes.PopulateDarsState: { - return { ...state, ...action.payload }; + case DarsActionTypes.DoneLoadingDARSView: { + return { + ...state, + ...action.payload, + }; } - case DarsActionTypes.ErrorLoadingMetadata: { + case DarsActionTypes.DoneRefreshingMetadata: { + const outstanding = + state.metadata.status === 'Loaded' + ? { ...state.metadata.outstanding } + : { program: 0, whatIf: 0 }; + return { ...state, metadata: { - status: 'Error', - message: action.payload.message, + status: 'Loaded', + outstanding, + ...groupAuditMetadata(action.payload, state.degreePrograms), }, }; } - case DarsActionTypes.AddAuditMetadata: { - const programMetadata = (state.metadata.status === 'Loaded' - ? state.metadata.programMetadata - : [] - ).concat(action.payload.programMetadata); + case DarsActionTypes.StartSendingAudit: { + if (state.metadata.status === 'Loaded') { + const outstanding = { ...state.metadata.outstanding }; + outstanding[action.payload.auditType]++; + + return { + ...state, + metadata: { + ...state.metadata, + outstanding, + }, + }; + } else { + return state; + } + } + case DarsActionTypes.DoneSendingAudit: { + if (state.metadata.status === 'Loaded') { + const outstanding = { ...state.metadata.outstanding }; + outstanding[action.payload.auditType]--; - const whatIfMetadata = (state.metadata.status === 'Loaded' - ? state.metadata.whatIfMetadata - : [] - ).concat(action.payload.whatIfMetadata); + const pending = { ...state.metadata.pending }; + pending[action.payload.auditType]++; + return { + ...state, + metadata: { + ...state.metadata, + outstanding, + pending, + }, + }; + } else { + return state; + } + } + case DarsActionTypes.ErrorSendingAudit: { + if (state.metadata.status === 'Loaded') { + const outstanding = { ...state.metadata.outstanding }; + outstanding[action.payload.auditType]--; + + return { + ...state, + metadata: { + ...state.metadata, + outstanding, + }, + }; + } else { + return state; + } + } + case DarsActionTypes.ErrorLoadingDARSView: { return { ...state, metadata: { - status: 'Loaded', - programMetadata: programMetadata, - whatIfMetadata, + status: 'Error', + message: action.payload.message, }, }; } diff --git a/src/app/dars/store/selectors.ts b/src/app/dars/store/selectors.ts index 2402931..ef72651 100644 --- a/src/app/dars/store/selectors.ts +++ b/src/app/dars/store/selectors.ts @@ -27,7 +27,24 @@ export const programMetadata = createSelector( if (state.metadata.status === 'Loaded') { return state.metadata.programMetadata; } else { - return []; + return {}; + } + }, +); + +export const outstandingAndPendingPrograms = createSelector( + getDARSState, + (state: DARSState) => { + if (state.metadata.status === 'Loaded') { + return { + outstanding: state.metadata.outstanding.program, + pending: state.metadata.pending.program, + }; + } else { + return { + outstanding: 0, + pending: 0, + }; } }, ); @@ -38,7 +55,24 @@ export const whatIfMetadata = createSelector( if (state.metadata.status === 'Loaded') { return state.metadata.whatIfMetadata; } else { - return []; + return {}; + } + }, +); + +export const outstandingAndPendingWhatIf = createSelector( + getDARSState, + (state: DARSState) => { + if (state.metadata.status === 'Loaded') { + return { + outstanding: state.metadata.outstanding.whatIf, + pending: state.metadata.pending.whatIf, + }; + } else { + return { + outstanding: 0, + pending: 0, + }; } }, ); diff --git a/src/app/dars/store/state.ts b/src/app/dars/store/state.ts index af68200..ac2f2d3 100644 --- a/src/app/dars/store/state.ts +++ b/src/app/dars/store/state.ts @@ -1,7 +1,8 @@ import { Alert } from './../../core/models/alert'; -import { AuditMetadata } from '../models/audit-metadata'; +import { AuditMetadata, AuditMetadataMap } from '../models/audit-metadata'; import { DegreePlan } from '@app/core/models/degree-plan'; import { Audit } from '../models/audit/audit'; +import { StudentDegreeProgram } from '../models/student-degree-program'; export type MetadataStatus = | { status: 'Error'; message: string } @@ -9,8 +10,10 @@ export type MetadataStatus = | { status: 'Loading' } | { status: 'Loaded'; - programMetadata: AuditMetadata[]; - whatIfMetadata: AuditMetadata[]; + outstanding: { program: number; whatIf: number }; + pending: { program: number; whatIf: number }; + programMetadata: AuditMetadataMap; + whatIfMetadata: AuditMetadataMap; }; export type AuditStatus = @@ -21,6 +24,7 @@ export type AuditStatus = export interface DARSState { degreePlans: DegreePlan[]; + degreePrograms: StudentDegreeProgram[]; metadata: MetadataStatus; audits: { [darsDegreeAuditReportId: number]: AuditStatus }; alerts: Alert[]; @@ -28,6 +32,7 @@ export interface DARSState { export const INITIAL_DARS_STATE: DARSState = { degreePlans: [], + degreePrograms: [], metadata: { status: 'NotLoaded' }, audits: {}, alerts: [], diff --git a/src/app/dars/store/utils.ts b/src/app/dars/store/utils.ts new file mode 100644 index 0000000..1265a24 --- /dev/null +++ b/src/app/dars/store/utils.ts @@ -0,0 +1,53 @@ +import { + AuditMetadataMap, + AuditMetadata, + AuditMetadataWithReportId, +} from '../models/audit-metadata'; +import { StudentDegreeProgram } from '../models/student-degree-program'; + +interface SortedMetadataTuple { + pending: { program: number; whatIf: number }; + programMetadata: AuditMetadataMap; + whatIfMetadata: AuditMetadataMap; +} + +const isFromDegreeProgram = (md: AuditMetadata, pg: StudentDegreeProgram) => { + return ( + md.darsDegreeProgramCode === pg.darsDegreeProgramCode && + md.darsInstitutionCode === pg.darsInstitutionCode + ); +}; + +export const groupAuditMetadata = ( + metadata: AuditMetadata[], + degreePrograms: StudentDegreeProgram[], +): SortedMetadataTuple => { + const empty: SortedMetadataTuple = { + pending: { program: 0, whatIf: 0 }, + programMetadata: {}, + whatIfMetadata: {}, + }; + + return metadata.reduce((tuple, next) => { + const auditType = degreePrograms.some(dp => isFromDegreeProgram(next, dp)) + ? 'program' + : 'whatIf'; + + if (next.darsDegreeAuditReportId === null) { + tuple.pending[auditType]++; + return tuple; + } + + if (auditType === 'program') { + tuple.programMetadata[ + next.darsDegreeAuditReportId + ] = next as AuditMetadataWithReportId; + } else { + tuple.whatIfMetadata[ + next.darsDegreeAuditReportId + ] = next as AuditMetadataWithReportId; + } + + return tuple; + }, empty); +}; -- GitLab