diff --git a/src/app/events/components/events-current/events-current.component.html b/src/app/events/components/events-current/events-current.component.html index b2d20d364..125e8eaba 100644 --- a/src/app/events/components/events-current/events-current.component.html +++ b/src/app/events/components/events-current/events-current.component.html @@ -1,2 +1,5 @@

{{ "events.current.title" | translate }}

- + diff --git a/src/app/events/components/events-list-entry/events-list-entry.component.html b/src/app/events/components/events-list-entry/events-list-entry.component.html index f9533b774..9589be112 100644 --- a/src/app/events/components/events-list-entry/events-list-entry.component.html +++ b/src/app/events/components/events-list-entry/events-list-entry.component.html @@ -1,5 +1,5 @@
- {{ event.Designation }} + {{ event.designation }}
{ component = fixture.componentInstance; element = fixture.debugElement.nativeElement; - component.event = buildEvent(1); + component.event = buildEventEntry(1); component.event.evaluationText = "Lorem ipsum"; }); diff --git a/src/app/events/components/events-list-entry/events-list-entry.component.ts b/src/app/events/components/events-list-entry/events-list-entry.component.ts index 149815663..bc4d1074a 100644 --- a/src/app/events/components/events-list-entry/events-list-entry.component.ts +++ b/src/app/events/components/events-list-entry/events-list-entry.component.ts @@ -2,7 +2,7 @@ import { DatePipe, NgIf } from "@angular/common"; import { Component, Input } from "@angular/core"; import { RouterLink } from "@angular/router"; import { TranslateModule } from "@ngx-translate/core"; -import { Event } from "../../services/events-state.service"; +import { EventEntry } from "../../services/events-state.service"; @Component({ selector: "bkd-events-list-entry", @@ -12,6 +12,6 @@ import { Event } from "../../services/events-state.service"; styleUrl: "./events-list-entry.component.scss", }) export class EventsListEntryComponent { - @Input() event: Event; - @Input() withRatings: boolean = true; + @Input() event: EventEntry; + @Input() withRatings = true; } diff --git a/src/app/events/components/events-list/events-list.component.html b/src/app/events/components/events-list/events-list.component.html index 506c0973c..70533dd80 100644 --- a/src/app/events/components/events-list/events-list.component.html +++ b/src/app/events/components/events-list/events-list.component.html @@ -1,6 +1,6 @@
{ let stateServiceMock: EventsStateService; let element: HTMLElement; let roles$: BehaviorSubject>>; + let setWithStudyCourses: jasmine.Spy; beforeEach(waitForAsync(() => { roles$ = new BehaviorSubject>>(null); + setWithStudyCourses = jasmine.createSpy("setWithStudyCourses"); stateServiceMock = { loading$: of(false), - events$: of([buildEvent(1)]), + events$: of([buildEventEntry(1)]), search$: of(""), roles$, setRoles(roles: Option>) { roles$.next(roles); }, - getEvents: () => of([buildEvent(1)]), + setWithStudyCourses, + getEntries: () => of([buildEventEntry(1)]), } as unknown as EventsStateService; TestBed.configureTestingModule( @@ -51,17 +54,33 @@ describe("EventsListComponent", () => { fixture.detectChanges(); }); - it("renders entry without ratings column", () => { - component.withRatings = false; + describe("withRatings", () => { + it("renders entry without ratings column", () => { + component.withRatings = false; - fixture.detectChanges(); - expect(element.textContent).not.toContain("events.rating"); + fixture.detectChanges(); + expect(element.textContent).not.toContain("events.rating"); + }); + + it("renders entry with ratings column", () => { + component.withRatings = true; + + fixture.detectChanges(); + expect(element.textContent).toContain("events.rating"); + }); }); - it("renders entry with ratings column", () => { - component.withRatings = true; + describe("withStudyCourses", () => { + it("enables study courses on state service if set to true", () => { + changeInput(component, "withStudyCourses", true); + fixture.detectChanges(); + expect(setWithStudyCourses).toHaveBeenCalledWith(true); + }); - fixture.detectChanges(); - expect(element.textContent).toContain("events.rating"); + it("does not enable study courses on state service if set to false", () => { + changeInput(component, "withStudyCourses", false); + fixture.detectChanges(); + expect(setWithStudyCourses).toHaveBeenCalledWith(false); + }); }); }); diff --git a/src/app/events/components/events-list/events-list.component.ts b/src/app/events/components/events-list/events-list.component.ts index ad1c06c29..0815be6ce 100644 --- a/src/app/events/components/events-list/events-list.component.ts +++ b/src/app/events/components/events-list/events-list.component.ts @@ -1,5 +1,5 @@ import { AsyncPipe, NgFor, NgIf } from "@angular/common"; -import { Component, Input } from "@angular/core"; +import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; import { TranslateModule } from "@ngx-translate/core"; import { ResettableInputComponent } from "../../../shared/components/resettable-input/resettable-input.component"; import { SpinnerComponent } from "../../../shared/components/spinner/spinner.component"; @@ -24,12 +24,20 @@ import { EventsListEntryComponent } from "../events-list-entry/events-list-entry EventsListEntryComponent, ], }) -export class EventsListComponent { - @Input() withRatings: boolean = true; +export class EventsListComponent implements OnChanges { + @Input() withStudyCourses = false; + @Input() withRatings = true; + constructor( public state: EventsStateService, private storage: StorageService, ) { this.state.setRoles(this.storage.getPayload()?.roles ?? null); } + + ngOnChanges(changes: SimpleChanges): void { + if (changes["withStudyCourses"]) { + this.state.setWithStudyCourses(changes["withStudyCourses"].currentValue); + } + } } diff --git a/src/app/events/services/events-state.service.spec.ts b/src/app/events/services/events-state.service.spec.ts index 4e8b6dc2a..6e7c68e08 100644 --- a/src/app/events/services/events-state.service.spec.ts +++ b/src/app/events/services/events-state.service.spec.ts @@ -2,6 +2,7 @@ import { HttpTestingController } from "@angular/common/http/testing"; import { TestBed } from "@angular/core/testing"; import * as t from "io-ts/lib/index"; import { Course } from "src/app/shared/models/course.model"; +import { Event } from "src/app/shared/models/event.model"; import { StudyClass } from "src/app/shared/models/study-class.model"; import { buildCourse, @@ -9,18 +10,24 @@ import { buildStudyClass, } from "src/spec-builders"; import { buildTestModuleMetadata } from "src/spec-helpers"; -import { Event, EventState, EventsStateService } from "./events-state.service"; +import { + EventEntry, + EventState, + EventsStateService, +} from "./events-state.service"; describe("EventsStateService", () => { let service: EventsStateService; let httpTestingController: HttpTestingController; - let courseEvents: Event[]; let courses: Course[]; - let studyClassEvents: Event[]; + let courseEntries: EventEntry[]; + let studyCourses: Event[]; + let studyCoursesEntries: EventEntry[]; let studyClasses: StudyClass[]; + let studyClassEntries: EventEntry[]; let assessments: StudyClass[]; - let assessmentEvents: Event[]; + let assessmentEntries: EventEntry[]; beforeEach(() => { TestBed.configureTestingModule(buildTestModuleMetadata()); @@ -45,10 +52,10 @@ describe("EventsStateService", () => { }; studyClasses = [buildStudyClass(5, "22a"), buildStudyClass(6, "22b")]; - studyClassEvents = [ + studyClassEntries = [ { id: 5, - Designation: "22a", + designation: "22a", detailLink: "link-to-event-detail-module/5", studentCount: 0, state: null, @@ -56,10 +63,10 @@ describe("EventsStateService", () => { ]; assessments = [studyClasses[1]]; - assessmentEvents = [ + assessmentEntries = [ { id: 6, - Designation: "22b", + designation: "22b", detailLink: "link-to-event-detail-module/6", studentCount: 0, state: EventState.Rating, @@ -121,9 +128,9 @@ describe("EventsStateService", () => { ratedCourse, ]; - const courseEvent: Event = { + const courseEvent: EventEntry = { id: 1, - Designation: "Physik, 22a, 22b", + designation: "Physik, 22a, 22b", detailLink: "link-to-event-detail-module/1", dateFrom: new Date("2022-02-09T00:00:00"), dateTo: new Date("2022-06-30T00:00:00"), @@ -133,11 +140,11 @@ describe("EventsStateService", () => { evaluationLink: null, }; - courseEvents = [ + courseEntries = [ { ...courseEvent, id: 2, - Designation: "Bio, 22a", + designation: "Bio, 22a", detailLink: "link-to-event-detail-module/2", state: EventState.RatingUntil, evaluationText: "events.state.rating-until 03.06.2022", @@ -146,7 +153,7 @@ describe("EventsStateService", () => { { ...courseEvent, id: 4, - Designation: "Franz, 22a, 22b", + designation: "Franz, 22a, 22b", detailLink: "link-to-event-detail-module/4", state: EventState.Tests, evaluationText: "events.state.add-tests", @@ -155,13 +162,24 @@ describe("EventsStateService", () => { { ...courseEvent, id: 3, - Designation: "Zeichnen, 22b", + designation: "Zeichnen, 22b", detailLink: "link-to-event-detail-module/3", state: EventState.IntermediateRating, evaluationText: "events.state.intermediate-rating", evaluationLink: "link-to-evaluation-module/3", }, ]; + + studyCourses = [{ Id: 10, Designation: "Zoologie", StudentCount: 42 }]; + studyCoursesEntries = [ + { + id: 10, + designation: "Zoologie", + studentCount: 42, + detailLink: "link-to-event-detail-module/10", + state: null, + }, + ]; }); afterEach(() => { @@ -174,11 +192,11 @@ describe("EventsStateService", () => { }); it("loads events", () => { - service.getEvents().subscribe((result) => { + service.getEntries().subscribe((result) => { expect(result).toEqual([ - ...studyClassEvents, - ...assessmentEvents, - ...courseEvents, + ...studyClassEntries, + ...assessmentEntries, + ...courseEntries, ]); }); @@ -188,6 +206,26 @@ describe("EventsStateService", () => { httpTestingController.verify(); }); + + it("loads events with study courses", () => { + service.setWithStudyCourses(true); + + service.getEntries().subscribe((result) => { + expect(result).toEqual([ + ...studyClassEntries, + ...assessmentEntries, + ...courseEntries, + ...studyCoursesEntries, + ]); + }); + + expectCoursesRequest(); + expectStudyCoursesRequest(); + expectFormativeAssessmentsRequest(); + expectStudyClassesRequest(); + + httpTestingController.verify(); + }); }); describe("without ClassTeacherRole", () => { @@ -197,7 +235,7 @@ describe("EventsStateService", () => { it("loads events", () => { service - .getEvents() + .getEntries() .subscribe((result) => expect(result.map((r) => r.id)).toEqual([2, 4, 1, 3]), ); @@ -209,7 +247,7 @@ describe("EventsStateService", () => { it("loads events with ratings", () => { service - .getEvents(true) + .getEntries(true) .subscribe((result) => expect(result.map((r) => r.id)).toEqual([2, 4, 3]), ); @@ -229,6 +267,12 @@ describe("EventsStateService", () => { .flush(t.array(Course).encode(response)); } + function expectStudyCoursesRequest(response = studyCourses): void { + const url = "https://eventotest.api/Events/?filter.EventTypeId==1"; + + httpTestingController.expectOne(url).flush(t.array(Event).encode(response)); + } + function expectFormativeAssessmentsRequest(response = assessments): void { const url = "https://eventotest.api/StudyClasses/FormativeAssessments?filter.IsActive==true"; diff --git a/src/app/events/services/events-state.service.ts b/src/app/events/services/events-state.service.ts index ee6dca8ac..58400afda 100644 --- a/src/app/events/services/events-state.service.ts +++ b/src/app/events/services/events-state.service.ts @@ -6,13 +6,16 @@ import { Observable, combineLatest, map, + of, shareReplay, switchMap, } from "rxjs"; import { SETTINGS, Settings } from "src/app/settings"; import { Course } from "src/app/shared/models/course.model"; +import { Event } from "src/app/shared/models/event.model"; import { StudyClass } from "src/app/shared/models/study-class.model"; import { CoursesRestService } from "src/app/shared/services/courses-rest.service"; +import { EventsRestService } from "src/app/shared/services/events-rest.service"; import { LoadingService } from "src/app/shared/services/loading-service"; import { StudyClassesRestService } from "src/app/shared/services/study-classes-rest.service"; import { spread } from "src/app/shared/utils/function"; @@ -32,10 +35,9 @@ export enum EventState { Tests = "add-tests", } -type LinkType = "evaluation" | "eventdetail"; -export interface Event { +export interface EventEntry { id: number; - Designation: string; + designation: string; detailLink: string; dateFrom?: Option; dateTo?: Option; @@ -45,23 +47,47 @@ export interface Event { evaluationLink?: Option; } +type LinkType = "evaluation" | "eventdetail"; + @Injectable({ providedIn: "root" }) export class EventsStateService { loading$ = this.loadingService.loading$; + private searchSubject$ = new BehaviorSubject(""); search$ = this.searchSubject$.asObservable(); + private roles$ = new BehaviorSubject>(null); + private isClassTeacher$ = this.roles$.pipe( + map((roles) => hasRole(roles, "ClassTeacherRole")), + shareReplay(1), + ); + private withStudyCourses$ = new BehaviorSubject(false); - private formativeAssessments$ = - this.studyClassRestService.getActiveFormativeAssessments(); - private studyClasses$ = this.studyClassRestService.getActive(); + private unratedCourses$ = this.roles$.pipe( + switchMap(this.loadUnratedCourses.bind(this)), + shareReplay(1), + ); + private studyCourses$ = this.withStudyCourses$.pipe( + switchMap(this.loadStudyCourses.bind(this)), + shareReplay(1), + ); + private formativeAssessments$ = this.isClassTeacher$.pipe( + switchMap(this.loadFormativeAssessments.bind(this)), + shareReplay(1), + ); + private studyClasses$ = this.isClassTeacher$.pipe( + switchMap(this.loadStudyClasses.bind(this)), + shareReplay(1), + ); - private events$ = this.loadEvents().pipe(shareReplay(1)); + private events$ = this.getEvents().pipe(shareReplay(1)); private filteredEvents$ = combineLatest([this.events$, this.search$]).pipe( map(spread(searchEntries)), ); + constructor( private coursesRestService: CoursesRestService, + private eventsRestService: EventsRestService, private studyClassRestService: StudyClassesRestService, private loadingService: LoadingService, private translate: TranslateService, @@ -76,97 +102,86 @@ export class EventsStateService { this.roles$.next(roles); } - getEvents(withRatings = false): Observable> { - return this.filteredEvents$.pipe( - map((events) => - withRatings ? events.filter((e) => e.evaluationText) : events, - ), - ); + setWithStudyCourses(enabled: boolean): void { + this.withStudyCourses$.next(enabled); } - private loadEvents(): Observable> { - return this.roles$.pipe( - switchMap((roles) => - this.loadingService.load(this.loadEventsForRoles(roles)), + getEntries(withRatings = false): Observable> { + return this.filteredEvents$.pipe( + map((entries) => + withRatings ? entries.filter((e) => e.evaluationText) : entries, ), ); } - /** - * Events are derived either from courses or study classes. - * If the current user has the role 'ClassTeacherRole', additional requests to get study classes/formative assessments are made. - */ - private loadEventsForRoles( - roles: Maybe, - ): Observable> { - return hasRole(roles, "ClassTeacherRole") - ? combineLatest([ - this.loadCoursesNotRated(roles), + private getEvents(): Observable> { + return this.loadingService + .load( + combineLatest([ + this.unratedCourses$, + this.studyCourses$, this.formativeAssessments$, this.studyClasses$, - ]).pipe(map(spread(this.createAndSortEvents.bind(this)))) - : this.loadCoursesNotRated(roles).pipe( - map((course) => this.createAndSortEvents(course)), - ); + ]), + { + stopOnFirstValue: true, + }, + ) + .pipe(map(spread(this.createAndSortEvents.bind(this)))); } - private loadCoursesNotRated( - roles: Maybe, + private loadUnratedCourses( + roles: Option, ): Observable> { return this.coursesRestService .getExpandedCourses(roles) .pipe(map((courses) => courses.filter((c) => !isRated(c)))); } + private loadStudyCourses(enabled: boolean): Observable> { + return enabled ? this.eventsRestService.getStudyCourseEvents() : of([]); + } + + private loadFormativeAssessments( + isClassTeacher: boolean, + ): Observable> { + return isClassTeacher + ? this.studyClassRestService.getActiveFormativeAssessments() + : of([]); + } + + private loadStudyClasses( + isClassTeacher: boolean, + ): Observable> { + return isClassTeacher ? this.studyClassRestService.getActive() : of([]); + } + private createAndSortEvents( courses: ReadonlyArray, - formativeAssessments: ReadonlyArray = [], - studyClasses: ReadonlyArray = [], - ): ReadonlyArray { + studyCourses: ReadonlyArray, + formativeAssessments: ReadonlyArray, + studyClasses: ReadonlyArray, + ): ReadonlyArray { const classesWithoutAssessments = studyClasses.filter( (c) => !formativeAssessments.map((fa) => fa.Id).includes(c.Id), ); return [ ...this.createFromCourses(courses), + ...this.createFromStudyCourses(studyCourses), ...this.createFromAssessments(formativeAssessments), ...this.createFromStudyClasses(classesWithoutAssessments), - ].sort((a, b) => a.Designation.localeCompare(b.Designation)); - } - - private createFromStudyClasses( - studyClasses: ReadonlyArray, - ): ReadonlyArray { - return studyClasses.map((studyClass) => ({ - id: studyClass.Id, - Designation: studyClass.Number, - detailLink: this.buildLink(studyClass.Id, "eventdetail"), - studentCount: studyClass.StudentCount, - state: null, - })); - } - - private createFromAssessments( - studyClasses: ReadonlyArray, - ): ReadonlyArray { - const events = this.createFromStudyClasses(studyClasses); - - return events.map((e) => ({ - ...e, - state: EventState.Rating, - evaluationText: this.translate.instant("events.state.rating"), - evaluationLink: this.buildLink(e.id, "evaluation"), - })); + ].sort((a, b) => a.designation.localeCompare(b.designation)); } private createFromCourses( courses: ReadonlyArray, - ): ReadonlyArray { + ): ReadonlyArray { return courses.map((course) => { const state = getEventState(course); return { id: course.Id, - Designation: getCourseDesignation(course), + designation: getCourseDesignation(course), detailLink: this.buildLink(course.Id, "eventdetail"), studentCount: course.AttendanceRef.StudentCount || 0, dateFrom: course.DateFrom, @@ -181,6 +196,43 @@ export class EventsStateService { }); } + private createFromStudyCourses( + studyCourses: ReadonlyArray, + ): ReadonlyArray { + return studyCourses.map((studyCourse) => ({ + id: studyCourse.Id, + designation: studyCourse.Designation, + detailLink: this.buildLink(studyCourse.Id, "eventdetail"), + studentCount: studyCourse.StudentCount, + state: null, + })); + } + + private createFromAssessments( + studyClasses: ReadonlyArray, + ): ReadonlyArray { + const events = this.createFromStudyClasses(studyClasses); + + return events.map((e) => ({ + ...e, + state: EventState.Rating, + evaluationText: this.translate.instant("events.state.rating"), + evaluationLink: this.buildLink(e.id, "evaluation"), + })); + } + + private createFromStudyClasses( + studyClasses: ReadonlyArray, + ): ReadonlyArray { + return studyClasses.map((studyClass) => ({ + id: studyClass.Id, + designation: studyClass.Number, + detailLink: this.buildLink(studyClass.Id, "eventdetail"), + studentCount: studyClass.StudentCount, + state: null, + })); + } + private getEvaluationText( state: Option, date?: Maybe, diff --git a/src/app/shared/models/event.model.ts b/src/app/shared/models/event.model.ts new file mode 100644 index 000000000..82bb84855 --- /dev/null +++ b/src/app/shared/models/event.model.ts @@ -0,0 +1,52 @@ +import * as t from "io-ts"; + +const Event = t.type({ + Id: t.number, + // AreaOfEducation: t.string, + // AreaOfEducationId: t.number, + // EventCategory: t.string, + // EventCategoryId: t.number, + // EventLevel: t.string, + // EventLevelId: t.number, + // EventType: t.string, + // EventTypeId: t.number, + // Host: t.string, + // HostId: t.string, + // Status: t.string, + // StatusId: t.number, + // AllowSubscriptionByStatus: t.boolean, + // AllowSubscriptionInternetByStatus: t.boolean, + // DateFrom: null, + // DateTo: null, + Designation: t.string, + // Duration: null, + // FreeSeats: null, + // HasQueue: t.boolean, + // HighPrice: t.number, + // LanguageOfInstruction: null, + // Leadership: t.string, + // Location: null, + // MaxParticipants: t.number, + // MinParticipants: t.number, + // Number: t.string, + // Price: t.number, + // StatusDate: null, + // SubscriptionDateFrom: null, + // SubscriptionDateTo: null, + // SubscriptionTimeFrom: null, + // SubscriptionTimeTo: null, + // TimeFrom: null, + // TimeTo: null, + // TypeOfSubscription: t.number, + // Weekday: null, + // IdObject: t.number, + // StatusText: null, + // Management: null, + // GradingScaleId: null, + // DateString: t.string, + StudentCount: t.number, + // HRef: t.string, +}); + +type Event = t.TypeOf; +export { Event }; diff --git a/src/app/shared/services/events-rest.service.spec.ts b/src/app/shared/services/events-rest.service.spec.ts index 00c3b5c3c..0e78a94f2 100644 --- a/src/app/shared/services/events-rest.service.spec.ts +++ b/src/app/shared/services/events-rest.service.spec.ts @@ -15,6 +15,22 @@ describe("EventsRestService", () => { httpTestingController = TestBed.inject(HttpTestingController); }); + describe(".getStudyCourseEvents", () => { + it("filters returns the events with EventTypeId=1", () => { + const data: any[] = []; + const url = "https://eventotest.api/Events/?filter.EventTypeId==1"; + + service + .getStudyCourseEvents() + .subscribe((result) => expect(result).toBe(data)); + + httpTestingController + .expectOne((req) => req.urlWithParams === url, url) + .flush(data); + httpTestingController.verify(); + }); + }); + describe(".getSubscriptionDetailsDefinitions", () => { it("gets the list of subscription details definitions for the given event id", () => { const data: any[] = []; diff --git a/src/app/shared/services/events-rest.service.ts b/src/app/shared/services/events-rest.service.ts index 5c5df59fb..fc7015419 100644 --- a/src/app/shared/services/events-rest.service.ts +++ b/src/app/shared/services/events-rest.service.ts @@ -1,8 +1,9 @@ -import { HttpClient } from "@angular/common/http"; +import { HttpClient, HttpParams } from "@angular/common/http"; import { Inject, Injectable } from "@angular/core"; import { Observable } from "rxjs"; import { switchMap } from "rxjs/operators"; import { SETTINGS, Settings } from "../../settings"; +import { Event } from "../models/event.model"; import { SubscriptionDetail } from "../models/subscription-detail.model"; import { decodeArray } from "../utils/decode"; import { RestService } from "./rest.service"; @@ -10,9 +11,14 @@ import { RestService } from "./rest.service"; @Injectable({ providedIn: "root", }) -export class EventsRestService extends RestService { +export class EventsRestService extends RestService { constructor(http: HttpClient, @Inject(SETTINGS) settings: Settings) { - super(http, settings, SubscriptionDetail, "Events"); + super(http, settings, Event, "Events"); + } + + getStudyCourseEvents(): Observable> { + const params = new HttpParams().set("filter.EventTypeId=", "1"); + return this.getList({ params }); } getSubscriptionDetailsDefinitions( @@ -20,6 +26,6 @@ export class EventsRestService extends RestService { ): Observable> { return this.http .get(`${this.baseUrl}/${eventId}/SubscriptionDetails`) - .pipe(switchMap(decodeArray(this.codec))); + .pipe(switchMap(decodeArray(SubscriptionDetail))); } } diff --git a/src/app/shared/services/loading-service.spec.ts b/src/app/shared/services/loading-service.spec.ts index 3381b08b3..4def140ec 100644 --- a/src/app/shared/services/loading-service.spec.ts +++ b/src/app/shared/services/loading-service.spec.ts @@ -1,12 +1,117 @@ import { TestBed } from "@angular/core/testing"; +import { Observable } from "rxjs"; import { buildTestModuleMetadata } from "src/spec-helpers"; import { LoadingService } from "./loading-service"; describe("LoadingService", () => { - beforeEach(() => TestBed.configureTestingModule(buildTestModuleMetadata({}))); + let service: LoadingService; + let loadingDefault: jasmine.Spy; + let loadingCustom: jasmine.Spy; - it("should be created", () => { - const service: LoadingService = TestBed.inject(LoadingService); - expect(service).toBeTruthy(); + beforeEach(() => { + TestBed.configureTestingModule(buildTestModuleMetadata({})); + service = TestBed.inject(LoadingService); + + loadingDefault = jasmine.createSpy("loadingDefault"); + service.loading().subscribe(loadingDefault); + + loadingCustom = jasmine.createSpy("loadingCustom"); + service.loading("custom").subscribe(loadingCustom); + }); + + it("tracks the loading states of two separate contexts", () => { + expectCalledWith(loadingDefault, false); + expectCalledWith(loadingCustom, false); + + // First observable in default context + const [default$, nextDefault, completeDefault] = createObservable(); + const resultDefault = jasmine.createSpy("resultDefault"); + const resultDefault$ = service.load(default$); + expectNotCalled(loadingDefault); + expectNotCalled(loadingCustom); + + resultDefault$.subscribe(resultDefault); + expectNotCalled(resultDefault); + expectCalledWith(loadingDefault, true); + expectNotCalled(loadingCustom); + + // Second observable in custom context + const [custom$, nextCustom, completeCustom] = createObservable(); + const resultCustom = jasmine.createSpy("resultCustom"); + const resultCustom$ = service.load(custom$, "custom"); + expectNotCalled(loadingDefault); + expectNotCalled(loadingCustom); + + resultCustom$.subscribe(resultCustom); + expectNotCalled(resultCustom); + expectNotCalled(loadingDefault); + expectCalledWith(loadingCustom, true); + + // Both observables emit a value + nextDefault(1); + nextCustom(2); + expectCalledWith(resultDefault, 1); + expectCalledWith(resultCustom, 2); + expectNotCalled(loadingDefault); + expectNotCalled(loadingCustom); + + // Default observable completes + completeDefault(); + expectCalledWith(loadingDefault, false); + expectNotCalled(loadingCustom); + + // Custom observable completes + completeCustom(); + expectNotCalled(loadingDefault); + expectCalledWith(loadingCustom, false); }); + + it("stops loading on first value with stopOnFirstValue=true", () => { + expectCalledWith(loadingDefault, false); + const [default$, nextDefault, completeDefault] = createObservable(); + const resultDefault = jasmine.createSpy("resultDefault"); + const resultDefault$ = service.load(default$, { stopOnFirstValue: true }); + expectNotCalled(loadingDefault); + + resultDefault$.subscribe(resultDefault); + expectNotCalled(resultDefault); + expectCalledWith(loadingDefault, true); + + nextDefault(1); + expectCalledWith(loadingDefault, false); + + completeDefault(); + expectNotCalled(loadingDefault); + }); + + function createObservable(): [ + Observable, + (value: number) => void, + () => void, + ] { + let next: (value: number) => void | undefined; + let complete: () => void | undefined; + + const source$ = new Observable((subscriber) => { + next = (value: number) => subscriber.next(value); + complete = () => subscriber.complete(); + }); + + return [ + source$, + (value: number) => next && next(value), + () => complete && complete(), + ]; + } + + function expectNotCalled(spy: jasmine.Spy): void { + expect(spy).not.toHaveBeenCalled(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function expectCalledWith(spy: jasmine.Spy, args: any): void { + expect(spy).toHaveBeenCalledWith(args); + // expect(spy.calls.mostRecent().args).toEqual(args); + spy.calls.reset(); + } }); diff --git a/src/app/shared/services/loading-service.ts b/src/app/shared/services/loading-service.ts index 0d32d18e0..c6fb609c9 100644 --- a/src/app/shared/services/loading-service.ts +++ b/src/app/shared/services/loading-service.ts @@ -12,6 +12,7 @@ import { map, scan, startWith, + tap, } from "rxjs/operators"; import { prepare } from "../utils/observable"; @@ -62,6 +63,9 @@ export class LoadingService implements OnDestroy { this.loadingCountsSub.unsubscribe(); } + /** + * Returns an observable that returns the loading state of the given context. + */ loading(context = DEFAULT_CONTEXT): Observable { return this.loadingCounts$.pipe( map((counts) => counts[context]), @@ -70,10 +74,37 @@ export class LoadingService implements OnDestroy { ); } - load(source$: Observable, context = DEFAULT_CONTEXT): Observable { + /** + * Wrap your observable with this function to update the loading state. With + * the option `stopOnFirstValue` enabled, the loading state will be set to + * `false` once the observable emits the first value. Per default the loading + * state will be set to `false` once the observable completes. + */ + load( + source$: Observable, + options: + | string // context + | { context?: string; stopOnFirstValue?: boolean } = DEFAULT_CONTEXT, + ): Observable { + const context = + typeof options === "string" + ? options + : options.context || DEFAULT_CONTEXT; + const stopOnFirstValue = + (typeof options === "object" && options.stopOnFirstValue) || false; + + const decrement = this.decrementLoadingCount(context); + let firstValue = true; + function decrementOnce() { + if (firstValue) { + decrement(); + firstValue = false; + } + } + return source$.pipe( prepare(this.incrementLoadingCount(context)), - finalize(this.decrementLoadingCount(context)), + stopOnFirstValue ? tap(decrementOnce) : finalize(decrement), ); } diff --git a/src/spec-builders.ts b/src/spec-builders.ts index fddefd531..d2301e986 100644 --- a/src/spec-builders.ts +++ b/src/spec-builders.ts @@ -1,4 +1,7 @@ -import { Event, EventState } from "./app/events/services/events-state.service"; +import { + EventEntry, + EventState, +} from "./app/events/services/events-state.service"; import { PresenceControlEntry } from "./app/presence-control/models/presence-control-entry.model"; import { ApprenticeshipContract } from "./app/shared/models/apprenticeship-contract.model"; import { ApprenticeshipManager } from "./app/shared/models/apprenticeship-manager.model"; @@ -599,9 +602,9 @@ export function buildTimetableEntry( }; } -export function buildEvent(id: number): Event { +export function buildEventEntry(id: number): EventEntry { return { - Designation: "Französisch-S2, 24a", + designation: "Französisch-S2, 24a", detailLink: "", id: id, state: EventState.Tests,