From cf6703f70fb7c299321571c8a20aa4eb78a6fe65 Mon Sep 17 00:00:00 2001 From: ievavold <ievavold@wisc.edu> Date: Tue, 19 Feb 2019 14:31:39 -0600 Subject: [PATCH] ROENROLL-1373 --- src/app/core/models/planned-term.ts | 7 +++- .../notes-dialog/notes-dialog.component.ts | 21 ++++++++-- .../store/actions/note.actions.ts | 6 ++- .../store/effects/note.effects.ts | 24 ++++------- .../store/effects/plan.effects.ts | 12 ++++-- src/app/degree-planner/store/reducer.ts | 42 ++++++++++++++++--- .../term-container.component.html | 33 +++++++++------ .../term-container.component.scss | 11 +++++ .../term-container.component.ts | 23 ++++++---- src/app/shared/shared.module.ts | 2 + 10 files changed, 128 insertions(+), 53 deletions(-) diff --git a/src/app/core/models/planned-term.ts b/src/app/core/models/planned-term.ts index 1ec2722..0446370 100644 --- a/src/app/core/models/planned-term.ts +++ b/src/app/core/models/planned-term.ts @@ -1,11 +1,14 @@ -import { Note } from '@app/core/models/note'; import { Course } from '@app/core/models/course'; export type PlannedTermEra = 'past' | 'active' | 'future'; +export type PlannedTermNote = + | { isLoaded: true; text: string; id: number } + | { isLoaded: false; text: string }; + export interface PlannedTerm { termCode: string; era: PlannedTermEra; - note?: Note; + note?: PlannedTermNote; courses: Course[]; } diff --git a/src/app/degree-planner/dialogs/notes-dialog/notes-dialog.component.ts b/src/app/degree-planner/dialogs/notes-dialog/notes-dialog.component.ts index caade24..c807f0c 100644 --- a/src/app/degree-planner/dialogs/notes-dialog/notes-dialog.component.ts +++ b/src/app/degree-planner/dialogs/notes-dialog/notes-dialog.component.ts @@ -12,8 +12,18 @@ import { } from '@app/degree-planner/store/actions/note.actions'; export type NotesDialogData = - | { termCode: string; hasExistingNote: true; initialText: string } - | { termCode: string; hasExistingNote: false; initialText?: never }; + | { + termCode: string; + hasExistingNote: true; + initialText: string; + noteId: number; + } + | { + termCode: string; + hasExistingNote: false; + initialText?: never; + noteId?: never; + }; @Component({ selector: 'cse-notes-dialog', @@ -51,7 +61,10 @@ export class NotesDialogComponent implements OnInit { public deleteNote() { const termCode = this.data.termCode; - this.store.dispatch(new DeleteNote({ termCode })); - this.dialogRef.close({ event: 'remove' }); + if (this.data.noteId !== undefined) { + const noteId = this.data.noteId; + this.store.dispatch(new DeleteNote({ termCode, noteId })); + this.dialogRef.close({ event: 'remove' }); + } } } diff --git a/src/app/degree-planner/store/actions/note.actions.ts b/src/app/degree-planner/store/actions/note.actions.ts index 48a4db8..567acf6 100644 --- a/src/app/degree-planner/store/actions/note.actions.ts +++ b/src/app/degree-planner/store/actions/note.actions.ts @@ -23,7 +23,7 @@ export class WriteNoteSuccess implements Action { export class DeleteNote implements Action { public readonly type = NoteActionTypes.DeleteNote; - constructor(public payload: { termCode: string }) {} + constructor(public payload: { termCode: string; noteId: number }) {} } export class DeleteNoteSuccess implements Action { @@ -33,5 +33,7 @@ export class DeleteNoteSuccess implements Action { export class NoteError implements Action { public readonly type = NoteActionTypes.NoteError; - constructor(public payload: { message: string; duration: number; error: any }) {} + constructor( + public payload: { message: string; duration: number; error: any }, + ) {} } diff --git a/src/app/degree-planner/store/effects/note.effects.ts b/src/app/degree-planner/store/effects/note.effects.ts index 072a0a7..6768178 100644 --- a/src/app/degree-planner/store/effects/note.effects.ts +++ b/src/app/degree-planner/store/effects/note.effects.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Actions, Effect, ofType } from '@ngrx/effects'; -import { Observable, of } from 'rxjs'; +import { of } from 'rxjs'; import { mergeMap, withLatestFrom, @@ -10,6 +10,7 @@ import { map, filter, catchError, + delay, } from 'rxjs/operators'; import { MatSnackBar } from '@angular/material'; @@ -47,6 +48,8 @@ export class NoteEffects { write$ = this.actions$.pipe( ofType<WriteNote>(NoteActionTypes.WriteNote), + delay(5000), + // Get the most recent Degree Planner state object from the store. This is // used to decide to fire either the `updateNote` API or `createNote` API. withLatestFrom(this.store$.select(selectors.selectVisibleDegreePlan)), @@ -64,7 +67,7 @@ export class NoteEffects { const termCode = action.payload.termCode; const noteText = action.payload.noteText; const existingNote = getExistingNote(years, termCode); - if (existingNote !== undefined) { + if (existingNote !== undefined && existingNote.isLoaded) { // Since the term DOES have a note, update the existing note const noteId = existingNote.id; return this.api.updateNote(planId, termCode, noteText, noteId); @@ -105,22 +108,11 @@ export class NoteEffects { // Only handle DeleteNote actions when a current plan ID is set. filter(([_, visibleDegreePlan]) => visibleDegreePlan !== undefined), - withLatestFrom(this.store$.select(selectors.selectAllVisibleYears)), - // Using the action and State objects, fire the `deleteNote` API. - mergeMap(([[action, visibleDegreePlan], years]) => { + mergeMap(([action, visibleDegreePlan]) => { const planId = (visibleDegreePlan as DegreePlan).roadmapId; - const termCode = action.payload.termCode; - const existingNote = getExistingNote(years, termCode); - if (existingNote !== undefined) { - // Since the term DOES have a note, delete it - const noteId = existingNote.id; - return this.api.deleteNote(planId, noteId).pipe(map(() => termCode)); - } else { - // Since the term DID NOT already have a note, return an action that - // makes no changes to the current state - return of(termCode); - } + const { termCode, noteId } = action.payload; + return this.api.deleteNote(planId, noteId).pipe(map(() => termCode)); }), // Dispatch an `DeleteNoteSuccess` action so that the diff --git a/src/app/degree-planner/store/effects/plan.effects.ts b/src/app/degree-planner/store/effects/plan.effects.ts index 012d51b..4cacf32 100644 --- a/src/app/degree-planner/store/effects/plan.effects.ts +++ b/src/app/degree-planner/store/effects/plan.effects.ts @@ -40,7 +40,7 @@ import * as utils from '@app/degree-planner/shared/utils'; // Models import { DegreePlan } from '@app/core/models/degree-plan'; -import { PlannedTerm, PlannedTermEra } from '@app/core/models/planned-term'; +import { PlannedTerm, PlannedTermNote } from '@app/core/models/planned-term'; import { SubjectMapping } from '@app/core/models/course'; import { SavedForLaterCourse } from '@app/core/models/saved-for-later-course'; import { @@ -341,7 +341,10 @@ const loadPlanTerms = ( const visibleTerms$ = forkJoin(uniqueTerms$, notesAndTerms$).pipe( map(([uniqueTerms, { notes, terms }]) => { return uniqueTerms.map(termCode => { - const note = notes.find(matchesTermCode(termCode)); + const baseNote = notes.find(matchesTermCode(termCode)); + const note: PlannedTermNote | undefined = baseNote + ? { isLoaded: true, text: baseNote.note, id: baseNote.id } + : undefined; const term = terms.find(matchesTermCode(termCode)); const courses = (term ? term.courses : []).map(course => ({ ...course, @@ -396,7 +399,10 @@ const buildTerm = ( activeTermCodes: string[], ): PlannedTerm => { const termCode = yearCode + offset; - const note = notes.find(matchesTermCode(termCode)); + const baseNote = notes.find(matchesTermCode(termCode)); + const note: PlannedTermNote | undefined = baseNote + ? { isLoaded: true, text: baseNote.note, id: baseNote.id } + : undefined; const group = courses.find(matchesTermCode(termCode)); const era = pickTermEra(termCode, activeTermCodes); return { diff --git a/src/app/degree-planner/store/reducer.ts b/src/app/degree-planner/store/reducer.ts index 2f8e88b..02572ba 100644 --- a/src/app/degree-planner/store/reducer.ts +++ b/src/app/degree-planner/store/reducer.ts @@ -31,8 +31,9 @@ import { } from '@app/degree-planner/store/actions/course.actions'; import { NoteActionTypes, + WriteNote, WriteNoteSuccess, - DeleteNoteSuccess, + DeleteNote, } from '@app/degree-planner/store/actions/note.actions'; import { AddAcademicYearActionTypes, @@ -51,7 +52,11 @@ import { DegreePlan } from '@app/core/models/degree-plan'; import { Year, YearMapping } from '@app/core/models/year'; import { Course } from '@app/core/models/course'; import { Note } from '@app/core/models/note'; -import { PlannedTerm, PlannedTermEra } from '@app/core/models/planned-term'; +import { + PlannedTerm, + PlannedTermEra, + PlannedTermNote, +} from '@app/core/models/planned-term'; import * as utils from '@app/degree-planner/shared/utils'; type SupportedActions = @@ -59,8 +64,9 @@ type SupportedActions = | InitialLoadSuccess | SwitchPlan | SwitchPlanSuccess + | WriteNote | WriteNoteSuccess - | DeleteNoteSuccess + | DeleteNote | MoveCourseInsideTerm | MoveCourseBetweenTerms | RemoveCourse @@ -217,6 +223,30 @@ export function degreePlannerReducer( }; } + case NoteActionTypes.WriteNote: { + const { termCode, noteText } = action.payload; + const { yearCode, termName } = parseTermCode(termCode); + const year = state.visibleYears[yearCode]; + const existingNote = year ? year[termName].note : undefined; + + const newNote: PlannedTermNote = + existingNote && existingNote.isLoaded + ? { isLoaded: true, text: noteText, id: existingNote.id } + : { isLoaded: false, text: noteText }; + + const visibleYears: YearMapping = { + ...state.visibleYears, + [yearCode]: createYearWithNote( + termCode, + newNote, + state.activeTermCodes, + state.visibleYears[yearCode], + ), + }; + + return { ...state, visibleYears }; + } + /** * The `WriteNoteResponse` action is dispatched by the `Note.write$` effect * upon a successful response from the `updateNote` or `createNote` API @@ -235,7 +265,7 @@ export function degreePlannerReducer( ...state.visibleYears, [yearCode]: createYearWithNote( termCode, - updatedNote, + { isLoaded: true, text: updatedNote.note, id: updatedNote.id }, state.activeTermCodes, state.visibleYears[yearCode], ), @@ -249,7 +279,7 @@ export function degreePlannerReducer( * has been called and it is okay to remote the note with the given * termCode from the degree planner state. */ - case NoteActionTypes.DeleteNoteSuccess: { + case NoteActionTypes.DeleteNote: { const { termCode } = action.payload; const { yearCode } = parseTermCode(termCode); @@ -541,7 +571,7 @@ const generateYearForTermCode = ( const createYearWithNote = ( termCode: string, - note: Note | undefined, + note: PlannedTermNote | undefined, activeTermCodes: string[], year = generateYearForTermCode(termCode, activeTermCodes), ): Year => { diff --git a/src/app/degree-planner/term-container/term-container.component.html b/src/app/degree-planner/term-container/term-container.component.html index 0f75aab..100368f 100644 --- a/src/app/degree-planner/term-container/term-container.component.html +++ b/src/app/degree-planner/term-container/term-container.component.html @@ -4,28 +4,28 @@ <h2>{{ termCode | getTermDescription }}</h2> <div fxLayout="row" fxLayoutAlign="space-between center"> <p class="text-right semi-bold credits">{{ credits$ | async }} Cr</p> - <button mat-icon-button> - <ng-container *ngIf="(note$ | async) as note; else newNote"> + <ng-container *ngIf="(note$ | async) as note; else newNote"> + <button mat-icon-button [disabled]="note.isLoaded === false" (click)="openNotesDialog(note)"> <mat-icon aria-label="Open dialog with notes in this term" color="primary" matTooltip="Edit Note" - (click)="openNotesDialog(note)" matTooltipPosition="above"> insert_drive_file </mat-icon> - </ng-container> - <ng-template #newNote> + </button> + </ng-container> + <ng-template #newNote> + <button mat-icon-button (click)="openNotesDialog()"> <mat-icon aria-label="Open dialog with notes in this term" color="primary" matTooltip="Add Note" - (click)="openNotesDialog()" matTooltipPosition="above"> note_add </mat-icon> - </ng-template> - </button> + </button> + </ng-template> </div> </div> <div id="term-{{termCode}}" class="term-body" cdkDropList @@ -35,10 +35,19 @@ <!-- Render term note (if it exists) --> <ng-container *ngIf="note$ | async as note"> - <div class="note-item" (click)="openNotesDialog(note)"> - <p class="semi-bold">Note</p> - <p class="note-excerpt">{{ note.note }}</p> - </div> + <ng-container *ngIf="note.isLoaded; else noteIsLoading"> + <div class="note-item" (click)="openNotesDialog(note)"> + <p class="semi-bold">Note</p> + <p class="note-excerpt">{{ note.text }}</p> + </div> + </ng-container> + <ng-template #noteIsLoading> + <div class="note-item note-item-loading"> + <p class="semi-bold">Note</p> + <p class="note-excerpt">{{ note.text }}</p> + <mat-progress-spinner mode="indeterminate" diameter="24"></mat-progress-spinner> + </div> + </ng-template> </ng-container> <!-- Render list of courses in this term --> diff --git a/src/app/degree-planner/term-container/term-container.component.scss b/src/app/degree-planner/term-container/term-container.component.scss index 7a2fd22..6429a25 100644 --- a/src/app/degree-planner/term-container/term-container.component.scss +++ b/src/app/degree-planner/term-container/term-container.component.scss @@ -100,6 +100,17 @@ } } +.note-item-loading { + background: #eee; + border-color: #888; + + mat-progress-spinner { + position: absolute; + top: 8px; + right: 8px; + } +} + .no-courses { text-align: center; padding: 25px 0; diff --git a/src/app/degree-planner/term-container/term-container.component.ts b/src/app/degree-planner/term-container/term-container.component.ts index 5874b5b..12b0b87 100644 --- a/src/app/degree-planner/term-container/term-container.component.ts +++ b/src/app/degree-planner/term-container/term-container.component.ts @@ -13,7 +13,7 @@ import { // Models import * as actions from '@app/degree-planner/store/actions/course.actions'; import * as selectors from '@app/degree-planner/store/selectors'; -import { PlannedTerm } from '@app/core/models/planned-term'; +import { PlannedTerm, PlannedTermNote } from '@app/core/models/planned-term'; import { Note } from '@app/core/models/note'; import { Course } from '@app/core/models/course'; import { @@ -43,7 +43,7 @@ export class TermContainerComponent implements OnInit { public termCode: string; public term$: Observable<PlannedTerm>; - public note$: Observable<Note | undefined>; + public note$: Observable<PlannedTermNote | undefined>; public courses$: Observable<Course[]>; public credits$: Observable<number>; public isPastTerm$: Observable<boolean>; @@ -100,12 +100,19 @@ export class TermContainerComponent implements OnInit { ); } - openNotesDialog(note?: Note) { - const termCode = this.termCode; - const data: NotesDialogData = note - ? { termCode, hasExistingNote: true, initialText: note.note } - : { termCode, hasExistingNote: false }; - this.dialog.open(NotesDialogComponent, { data }); + openNotesDialog(note?: PlannedTermNote) { + if (note === undefined || note.isLoaded) { + const termCode = this.termCode; + const data: NotesDialogData = note + ? { + termCode, + hasExistingNote: true, + initialText: note.text, + noteId: note.id, + } + : { termCode, hasExistingNote: false }; + this.dialog.open(NotesDialogComponent, { data }); + } } openCourseSearch() { diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 4eea8fc..1f5827e 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -28,6 +28,7 @@ import { CourseDetailsDialogComponent } from '../degree-planner/dialogs/course-d import { ConfirmDialogComponent } from './dialogs/confirm-dialog/confirm-dialog.component'; import { PromptDialogComponent } from './dialogs/prompt-dialog/prompt-dialog.component'; +import { MatProgressSpinnerModule } from '@angular/material'; const modules = [ CommonModule, @@ -50,6 +51,7 @@ const modules = [ MatFormFieldModule, MatSnackBarModule, MatProgressBarModule, + MatProgressSpinnerModule, ]; const pipes = [ GetTermDescriptionPipe, -- GitLab