From c41bac1515ec8adaeeae087bbce60ef0a511a1a7 Mon Sep 17 00:00:00 2001 From: Patrick Bassner Date: Wed, 27 Dec 2023 19:07:16 +0100 Subject: [PATCH 1/5] add recently accessed courses to student dashboard and management view --- .../course/course-access-storage.service.ts | 32 +++++++++++++++++++ .../course-management-tab-bar.component.ts | 5 +++ .../manage/course-management.component.html | 7 +++- .../manage/course-management.component.ts | 22 +++++++++++-- .../app/overview/course-overview.component.ts | 5 +++ .../app/overview/courses.component.html | 18 ++++++++++- .../webapp/app/overview/courses.component.ts | 22 ++++++++++++- src/main/webapp/i18n/de/course.json | 1 + .../webapp/i18n/de/student-dashboard.json | 2 ++ src/main/webapp/i18n/en/course.json | 1 + .../webapp/i18n/en/student-dashboard.json | 2 ++ 11 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 src/main/webapp/app/course/course-access-storage.service.ts diff --git a/src/main/webapp/app/course/course-access-storage.service.ts b/src/main/webapp/app/course/course-access-storage.service.ts new file mode 100644 index 000000000000..0c20d56f651c --- /dev/null +++ b/src/main/webapp/app/course/course-access-storage.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { LocalStorageService } from 'ngx-webstorage'; + +@Injectable({ + providedIn: 'root', +}) +export class CourseAccessStorageService { + private static readonly STORAGE_KEY = 'artemis.courseAccess'; + + constructor(private localStorage: LocalStorageService) {} + + onCourseAccessed(courseId: number): void { + const courseAccessMap: { [key: number]: number } = this.localStorage.retrieve(CourseAccessStorageService.STORAGE_KEY) || {}; + + courseAccessMap[courseId] = Date.now(); + + if (Object.keys(courseAccessMap).length > 3) { + const oldestEntry = Object.entries(courseAccessMap).reduce((prev, curr) => (prev[1] < curr[1] ? prev : curr)); + delete courseAccessMap[oldestEntry[0]]; + } + + this.localStorage.store(CourseAccessStorageService.STORAGE_KEY, courseAccessMap); + } + + getLastAccessedCourses(): number[] { + const courseAccessMap: { [key: number]: number } = this.localStorage.retrieve(CourseAccessStorageService.STORAGE_KEY) || {}; + + return Object.entries(courseAccessMap) + .sort((a, b) => b[1] - a[1]) + .map((entry) => Number(entry[0])); + } +} diff --git a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts index 078dd1022bf9..4aabf1938fd3 100644 --- a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts +++ b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts @@ -31,6 +31,7 @@ import { CourseAdminService } from 'app/course/manage/course-admin.service'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { PROFILE_LOCALCI } from 'app/app.constants'; +import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; @Component({ selector: 'jhi-course-management-tab-bar', @@ -85,6 +86,7 @@ export class CourseManagementTabBarComponent implements OnInit, OnDestroy { private router: Router, private modalService: NgbModal, private profileService: ProfileService, + private courseAccessStorageService: CourseAccessStorageService, ) {} /** @@ -108,6 +110,9 @@ export class CourseManagementTabBarComponent implements OnInit, OnDestroy { this.localCIActive = profileInfo?.activeProfiles.includes(PROFILE_LOCALCI); } }); + + // Notify the course access storage service that the course has been accessed + this.courseAccessStorageService.onCourseAccessed(courseId); } /** diff --git a/src/main/webapp/app/course/manage/course-management.component.html b/src/main/webapp/app/course/manage/course-management.component.html index 705fa2743752..81c0de7f62b9 100644 --- a/src/main/webapp/app/course/manage/course-management.component.html +++ b/src/main/webapp/app/course/manage/course-management.component.html @@ -30,7 +30,7 @@

- @if (semester !== '' && semester !== 'test') { + @if (semester !== '' && semester !== 'test' && semester !== 'recent') { {{ 'artemisApp.course.semester' | artemisTranslate }}: {{ semester }} } @if (semester === '') { @@ -41,6 +41,11 @@

+ {{ 'artemisApp.course.recentlyAccessed' | artemisTranslate }} + + }

@if (!semesterCollapsed[semester]) {
diff --git a/src/main/webapp/app/course/manage/course-management.component.ts b/src/main/webapp/app/course/manage/course-management.component.ts index 8589a1b9cb2e..b9d525c5ae86 100644 --- a/src/main/webapp/app/course/manage/course-management.component.ts +++ b/src/main/webapp/app/course/manage/course-management.component.ts @@ -11,6 +11,7 @@ import { CourseManagementOverviewStatisticsDto } from 'app/course/manage/overvie import { EventManager } from 'app/core/util/event-manager.service'; import { faAngleDown, faAngleUp, faPlus } from '@fortawesome/free-solid-svg-icons'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; +import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; @Component({ selector: 'jhi-course', @@ -46,6 +47,7 @@ export class CourseManagementComponent implements OnInit, OnDestroy, AfterViewIn private alertService: AlertService, private eventManager: EventManager, private guidedTourService: GuidedTourService, + private courseAccessStorageService: CourseAccessStorageService, ) {} /** @@ -136,26 +138,40 @@ export class CourseManagementComponent implements OnInit, OnDestroy, AfterViewIn /** * Sorts the courses into the coursesBySemester map. * Fills the semesterCollapsed map depending on if the semester should be expanded by default. - * The first semester is always expanded. The test course group is also expanded. + * The first semester group, the test courses and the recently accessed courses are expanded by default. */ private sortCoursesIntoSemesters(): void { this.semesterCollapsed = {}; this.coursesBySemester = {}; + // Get last accessed courses + const lastAccessedCourseIds = this.courseAccessStorageService.getLastAccessedCourses(); + const recentlyAccessedCourses = this.courses.filter((course) => lastAccessedCourseIds.includes(course.id!)); + let firstExpanded = false; for (const semester of this.courseSemesters) { this.semesterCollapsed[semester] = firstExpanded; firstExpanded = true; - this.coursesBySemester[semester] = this.courses.filter((course) => !course.testCourse && (course.semester ?? '') === semester); + this.coursesBySemester[semester] = this.courses.filter( + (course) => !course.testCourse && !lastAccessedCourseIds.includes(course.id!) && (course.semester ?? '') === semester, + ); } + // Add a new category "recent" + this.courseSemesters.unshift('recent'); + this.semesterCollapsed['recent'] = false; + this.coursesBySemester['recent'] = recentlyAccessedCourses; + // Add an extra category for test courses - const testCourses = this.courses.filter((course) => course.testCourse); + const testCourses = this.courses.filter((course) => course.testCourse && !lastAccessedCourseIds.includes(course.id!)); if (testCourses.length > 0) { this.courseSemesters[this.courseSemesters.length] = 'test'; this.semesterCollapsed['test'] = false; this.coursesBySemester['test'] = testCourses; } + + // Remove all semesters that have no courses + this.courseSemesters = this.courseSemesters.filter((semester) => this.coursesBySemester[semester].length > 0); } /** diff --git a/src/main/webapp/app/overview/course-overview.component.ts b/src/main/webapp/app/overview/course-overview.component.ts index 9be31b0d9db4..064d4ed0b4ea 100644 --- a/src/main/webapp/app/overview/course-overview.component.ts +++ b/src/main/webapp/app/overview/course-overview.component.ts @@ -37,6 +37,7 @@ import { FeatureToggle } from 'app/shared/feature-toggle/feature-toggle.service' import { TutorialGroupsService } from 'app/course/tutorial-groups/services/tutorial-groups.service'; import { TutorialGroupsConfigurationService } from 'app/course/tutorial-groups/services/tutorial-groups-configuration.service'; import { CourseStorageService } from 'app/course/manage/course-storage.service'; +import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; @Component({ selector: 'jhi-course-overview', @@ -116,6 +117,7 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit private tutorialGroupsConfigurationService: TutorialGroupsConfigurationService, private metisConversationService: MetisConversationService, private router: Router, + private courseAccessStorageService: CourseAccessStorageService, ) {} async ngOnInit() { @@ -125,6 +127,9 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit this.course = this.courseStorageService.getCourse(this.courseId); + // Notify the course access storage service that the course has been accessed + this.courseAccessStorageService.onCourseAccessed(this.courseId); + await this.loadCourse().toPromise(); await this.initAfterCourseLoad(); } diff --git a/src/main/webapp/app/overview/courses.component.html b/src/main/webapp/app/overview/courses.component.html index 6d3f17331ac3..c41202dee70f 100644 --- a/src/main/webapp/app/overview/courses.component.html +++ b/src/main/webapp/app/overview/courses.component.html @@ -49,8 +49,24 @@

Your curr {{ 'artemisApp.studentDashboard.enroll.title' | artemisTranslate }}

+@if (recentlyAccessedCourses.length > 0) { +
+

Recently Accessed Courses

+
+
+ @for (course of recentlyAccessedCourses; track course) { + + + } +
+ @if (regularCourses.length > 0) { +
+

Other Courses

+
+ } +}
- @for (course of courses; track course) { + @for (course of regularCourses; track course) { }
diff --git a/src/main/webapp/app/overview/courses.component.ts b/src/main/webapp/app/overview/courses.component.ts index f398a5028dd6..10e315caf4b9 100644 --- a/src/main/webapp/app/overview/courses.component.ts +++ b/src/main/webapp/app/overview/courses.component.ts @@ -17,6 +17,7 @@ import { ExamManagementService } from 'app/exam/manage/exam-management.service'; import { Router } from '@angular/router'; import { ArtemisServerDateService } from 'app/shared/server-date.service'; import { faPenAlt } from '@fortawesome/free-solid-svg-icons'; +import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; @Component({ selector: 'jhi-overview', @@ -24,12 +25,15 @@ import { faPenAlt } from '@fortawesome/free-solid-svg-icons'; styleUrls: ['./courses.component.scss'], }) export class CoursesComponent implements OnInit, OnChanges, OnDestroy { - public courses: Course[]; + courses: Course[]; public nextRelevantCourse?: Course; nextRelevantCourseForExam?: Course; nextRelevantExams?: Exam[]; exams: Exam[] = []; + public recentlyAccessedCourses: Course[] = []; + public regularCourses: Course[] = []; + courseForGuidedTour?: Course; quizExercisesChannels: string[] = []; @@ -49,6 +53,7 @@ export class CoursesComponent implements OnInit, OnChanges, OnDestroy { private examService: ExamManagementService, private router: Router, private serverDateService: ArtemisServerDateService, + private courseAccessStorageService: CourseAccessStorageService, ) {} async ngOnInit() { @@ -93,6 +98,7 @@ export class CoursesComponent implements OnInit, OnChanges, OnDestroy { (exam) => !exam.testExam! && timeNow.isBefore(exam.endDate!) && timeNow.isAfter(exam.visibleDate!), ); this.nextRelevantExercise = this.findNextRelevantExercise(); + this.sortCoursesInRecentlyAccessedAndRegularCourses(); } }, }); @@ -131,6 +137,20 @@ export class CoursesComponent implements OnInit, OnChanges, OnDestroy { } } + /** + * Sorts the courses into recently accessed and regular courses. + * If there are less than 5 courses, all courses are displayed in the regular courses section. + */ + sortCoursesInRecentlyAccessedAndRegularCourses() { + if (this.courses.length <= 5) { + this.regularCourses = this.courses; + } else { + const lastAccessedCourseIds = this.courseAccessStorageService.getLastAccessedCourses(); + this.recentlyAccessedCourses = this.courses.filter((course) => lastAccessedCourseIds.includes(course.id!)); + this.regularCourses = this.courses.filter((course) => !lastAccessedCourseIds.includes(course.id!)); + } + } + /** * Sets the course for the next upcoming exam and returns the next upcoming exam or undefined */ diff --git a/src/main/webapp/i18n/de/course.json b/src/main/webapp/i18n/de/course.json index 736e7f87c5f0..f38ca69f5246 100644 --- a/src/main/webapp/i18n/de/course.json +++ b/src/main/webapp/i18n/de/course.json @@ -65,6 +65,7 @@ "startDate": "Start", "endDate": "Ende", "semester": "Semester", + "recentlyAccessed": "Zuletzt verwendet", "maxPoints": { "title": "Maximale Punktzahl", "info": "Dieser Wert wird für die Berechnung von Beispielen (z.B. im Notenschlüssel) verwendet und hat keinen Einfluss auf die Noten der Studierenden. Die Noten werden mit Hilfe der im Kurs erreichbaren Punkte berechnet." diff --git a/src/main/webapp/i18n/de/student-dashboard.json b/src/main/webapp/i18n/de/student-dashboard.json index ba8be7b9ae50..c9afa0a37d89 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -2,6 +2,8 @@ "artemisApp": { "studentDashboard": { "title": "Deine aktuellen Kurse", + "recentlyAccessed": "Zuletzt besuchte Kurse", + "otherCourses": "Andere Kurse", "exerciseTitle": "Aktuelle aktive Übung von \"{{ course }}\"", "exerciseTitleWithoutDueDate": "Aktuelle Übung von \"{{ course }}\":", "examTitle": "Aktuelle Prüfung von \"{{ course }}\":", diff --git a/src/main/webapp/i18n/en/course.json b/src/main/webapp/i18n/en/course.json index 7454a6b08cfa..53965e1e3ecf 100644 --- a/src/main/webapp/i18n/en/course.json +++ b/src/main/webapp/i18n/en/course.json @@ -65,6 +65,7 @@ "startDate": "Start", "endDate": "End", "semester": "Semester", + "recentlyAccessed": "Recently accessed", "maxPoints": { "title": "Maximum number of points for course", "info": "This value is used for example calculations (e.g. in the grading key) and does not influence the students' grades. The grades are calculated based on the points achievable in the course." diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index 1797b7d68581..c910408764b8 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -2,6 +2,8 @@ "artemisApp": { "studentDashboard": { "title": "Your current courses", + "recentlyAccessed": "Recently accessed courses", + "otherCourses": "Other courses", "exerciseTitle": "Current active exercise in \"{{ course }}\"", "exerciseTitleWithoutDueDate": "Current exercise in \"{{ course }}\":", "examTitle": "Current exam in \"{{ course }}\":", From 313ec0df9b0a236c03ce1edbe2232c5dcbc21a8c Mon Sep 17 00:00:00 2001 From: Patrick Bassner Date: Fri, 29 Dec 2023 11:56:18 +0100 Subject: [PATCH 2/5] add tests --- .../webapp/app/overview/courses.component.ts | 3 +- ...ourse-management-tab-bar.component.spec.ts | 9 +++- .../course-management.component.spec.ts | 47 ++++++++++++++++++- .../component/course/course.component.spec.ts | 42 +++++++++++++++++ .../course-access-storage.service.spec.ts | 41 ++++++++++++++++ 5 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 src/test/javascript/spec/service/course-access-storage.service.spec.ts diff --git a/src/main/webapp/app/overview/courses.component.ts b/src/main/webapp/app/overview/courses.component.ts index 6bddbe34c2be..d71cb1ef2518 100644 --- a/src/main/webapp/app/overview/courses.component.ts +++ b/src/main/webapp/app/overview/courses.component.ts @@ -15,6 +15,7 @@ import { Exam } from 'app/entities/exam.model'; import { Router } from '@angular/router'; import { faPenAlt } from '@fortawesome/free-solid-svg-icons'; import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; +import { CourseForDashboardDTO } from 'app/course/manage/course-for-dashboard-dto'; @Component({ selector: 'jhi-overview', @@ -71,7 +72,7 @@ export class CoursesComponent implements OnInit, OnChanges, OnDestroy { next: (res: HttpResponse) => { if (res.body) { const courses: Course[] = []; - res.body.courses.forEach((courseDto) => { + res.body.courses.forEach((courseDto: CourseForDashboardDTO) => { courses.push(courseDto.course); }); this.courses = courses.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? '')); diff --git a/src/test/javascript/spec/component/course/course-management-tab-bar.component.spec.ts b/src/test/javascript/spec/component/course/course-management-tab-bar.component.spec.ts index 39520e31dfac..954729f5fe78 100644 --- a/src/test/javascript/spec/component/course/course-management-tab-bar.component.spec.ts +++ b/src/test/javascript/spec/component/course/course-management-tab-bar.component.spec.ts @@ -17,6 +17,7 @@ import { HasAnyAuthorityDirective } from 'app/shared/auth/has-any-authority.dire import { FeatureToggleLinkDirective } from 'app/shared/feature-toggle/feature-toggle-link.directive'; import { FeatureToggleHideDirective } from 'app/shared/feature-toggle/feature-toggle-hide.directive'; import { MockRouter } from '../../helpers/mocks/mock-router'; +import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; describe('Course Management Tab Bar Component', () => { let component: CourseManagementTabBarComponent; @@ -25,6 +26,7 @@ describe('Course Management Tab Bar Component', () => { let courseManagementService: CourseManagementService; let courseAdminService: CourseAdminService; let eventManager: EventManager; + let courseAccessStorageService: CourseAccessStorageService; const router = new MockRouter(); router.setUrl(''); @@ -60,6 +62,7 @@ describe('Course Management Tab Bar Component', () => { }, { provide: Router, useValue: router }, MockProvider(CourseManagementService), + MockProvider(CourseAccessStorageService), ], }) .compileComponents() @@ -69,6 +72,7 @@ describe('Course Management Tab Bar Component', () => { courseManagementService = TestBed.inject(CourseManagementService); courseAdminService = TestBed.inject(CourseAdminService); eventManager = TestBed.inject(EventManager); + courseAccessStorageService = TestBed.inject(CourseAccessStorageService); }); }); @@ -81,11 +85,14 @@ describe('Course Management Tab Bar Component', () => { jest.restoreAllMocks(); }); - it('should register changes in course on init', () => { + it('should register changes in course and notify courseAccessStorageService on init', () => { + const spy = jest.spyOn(courseAccessStorageService, 'onCourseAccessed'); + componentFixture.detectChanges(); component.ngOnInit(); expect(component.course).toEqual(course); + expect(spy).toHaveBeenCalledWith(course.id); }); it('should destroy event subscriber onDestroy', () => { diff --git a/src/test/javascript/spec/component/course/course-management.component.spec.ts b/src/test/javascript/spec/component/course/course-management.component.spec.ts index 4ad8caf4a55e..b1b33b125911 100644 --- a/src/test/javascript/spec/component/course/course-management.component.spec.ts +++ b/src/test/javascript/spec/component/course/course-management.component.spec.ts @@ -24,12 +24,14 @@ import { Exercise } from 'app/entities/exercise.model'; import { SortByDirective } from 'app/shared/sort/sort-by.directive'; import { SortDirective } from 'app/shared/sort/sort.directive'; import { DocumentationButtonComponent } from 'app/shared/components/documentation-button/documentation-button.component'; +import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; describe('CourseManagementComponent', () => { let fixture: ComponentFixture; let component: CourseManagementComponent; let service: CourseManagementService; let guidedTourService: GuidedTourService; + let courseAccessStorageService: CourseAccessStorageService; const pastExercise = { dueDate: dayjs().subtract(6, 'days'), @@ -101,7 +103,12 @@ describe('CourseManagementComponent', () => { MockComponent(CourseManagementCardComponent), MockComponent(DocumentationButtonComponent), ], - providers: [{ provide: LocalStorageService, useClass: MockSyncStorage }, { provide: SessionStorageService, useClass: MockSyncStorage }, MockProvider(TranslateService)], + providers: [ + { provide: LocalStorageService, useClass: MockSyncStorage }, + { provide: SessionStorageService, useClass: MockSyncStorage }, + MockProvider(TranslateService), + MockProvider(CourseAccessStorageService), + ], }) .compileComponents() .then(() => { @@ -109,6 +116,7 @@ describe('CourseManagementComponent', () => { component = fixture.componentInstance; service = TestBed.inject(CourseManagementService); guidedTourService = TestBed.inject(GuidedTourService); + courseAccessStorageService = TestBed.inject(CourseAccessStorageService); }); }); @@ -131,4 +139,41 @@ describe('CourseManagementComponent', () => { fixture.detectChanges(); expect(component).not.toBeNull(); }); + + it('should correctly sort unique semester names', () => { + const course1 = { id: 1, semester: 'SS20' } as Course; + const course2 = { id: 2, semester: 'WS19' } as Course; + const course3 = { id: 3, semester: 'SS19' } as Course; + const course4 = { id: 4, semester: 'WS20' } as Course; + const course5 = { id: 5, semester: '' } as Course; // course with no semester + + component.courses = [course1, course2, course3, course4, course5]; + const sortedSemesters = component['getUniqueSemesterNamesSorted'](component.courses); + + expect(sortedSemesters).toEqual(['WS20', 'SS20', 'WS19', 'SS19', '']); + }); + + it('should correctly sort courses into semesters', () => { + const course1 = { id: 1, semester: 'SS20', testCourse: false } as Course; + const course2 = { id: 2, semester: 'WS19', testCourse: false } as Course; + const course3 = { id: 3, semester: 'SS19', testCourse: false } as Course; + const course4 = { id: 4, semester: 'SS19', testCourse: false } as Course; + const course5 = { id: 5, semester: '', testCourse: false } as Course; // course with no semester + const course6 = { id: 6, semester: 'WS20', testCourse: true } as Course; // test course + + component.courses = [course1, course2, course3, course4, course5, course6]; + component.courseSemesters = ['SS20', 'WS19', 'SS19', '']; + + // Simulate that course1 and course2 were recently accessed + jest.spyOn(courseAccessStorageService, 'getLastAccessedCourses').mockReturnValue([1, 2]); + + component['sortCoursesIntoSemesters'](); + + expect(component.coursesBySemester['recent']).toEqual([course1, course2]); + expect(component.coursesBySemester['SS20']).toEqual([]); + expect(component.coursesBySemester['WS19']).toEqual([]); + expect(component.coursesBySemester['SS19']).toEqual([course3, course4]); + expect(component.coursesBySemester['']).toEqual([course5]); + expect(component.coursesBySemester['test']).toEqual([course6]); + }); }); diff --git a/src/test/javascript/spec/component/course/course.component.spec.ts b/src/test/javascript/spec/component/course/course.component.spec.ts index 8fba2b8abe70..9cd5fc698aac 100644 --- a/src/test/javascript/spec/component/course/course.component.spec.ts +++ b/src/test/javascript/spec/component/course/course.component.spec.ts @@ -36,6 +36,7 @@ import dayjs from 'dayjs/esm'; import { Exam } from 'app/entities/exam.model'; import { QuizExercise, QuizMode } from 'app/entities/quiz/quiz-exercise.model'; import { InitializationState } from 'app/entities/participation/participation.model'; +import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; const endDate1 = dayjs().add(1, 'days'); const visibleDate1 = dayjs().subtract(1, 'days'); @@ -108,6 +109,7 @@ describe('CoursesComponent', () => { let courseService: CourseManagementService; let serverDateService: ArtemisServerDateService; let exerciseService: ExerciseService; + let courseAccessStorageService: CourseAccessStorageService; let router: Router; let location: Location; let httpMock: HttpTestingController; @@ -136,6 +138,7 @@ describe('CoursesComponent', () => { { provide: ActivatedRoute, useValue: route }, { provide: CourseExerciseRowComponent }, MockProvider(AlertService), + MockProvider(CourseAccessStorageService), ], }) .compileComponents() @@ -143,6 +146,7 @@ describe('CoursesComponent', () => { fixture = TestBed.createComponent(CoursesComponent); component = fixture.componentInstance; courseService = TestBed.inject(CourseManagementService); + courseAccessStorageService = TestBed.inject(CourseAccessStorageService); location = TestBed.inject(Location); TestBed.inject(GuidedTourService); serverDateService = TestBed.inject(ArtemisServerDateService); @@ -217,6 +221,44 @@ describe('CoursesComponent', () => { expect(component.nextRelevantExercise).toEqual(exercise1); expect(component.nextRelevantCourse).toEqual(exercise1.course); }); + + it('should sort courses into regular and recently accessed after loading', () => { + const findAllForDashboardSpy = jest.spyOn(courseService, 'findAllForDashboard'); + const sortCoursesInRecentlyAccessedAndRegularCoursesSpy = jest.spyOn(component, 'sortCoursesInRecentlyAccessedAndRegularCourses'); + findAllForDashboardSpy.mockReturnValue(of(new HttpResponse({ body: coursesDashboard, headers: new HttpHeaders() }))); + + component.ngOnInit(); + + expect(findAllForDashboardSpy).toHaveBeenCalledOnce(); + expect(sortCoursesInRecentlyAccessedAndRegularCoursesSpy).toHaveBeenCalledOnce(); + + const lastAccessedCourses = [1, 2]; + const recentCoursesSpy = jest.spyOn(courseAccessStorageService, 'getLastAccessedCourses').mockReturnValue(lastAccessedCourses); + + // Test for less than 5 courses + const courses = []; + for (let i = 1; i <= 3; i++) { + const course = { id: i }; + courses.push(course); + } + + component.courses = courses; + component.sortCoursesInRecentlyAccessedAndRegularCourses(); + expect(component.regularCourses).toEqual(courses); + expect(component.recentlyAccessedCourses).toEqual([]); + expect(recentCoursesSpy).not.toHaveBeenCalled(); + + // Test for more than 5 courses + for (let i = 4; i <= 7; i++) { + const course = { id: i }; + courses.push(course); + } + component.courses = courses; + component.sortCoursesInRecentlyAccessedAndRegularCourses(); + expect(component.regularCourses).toEqual(courses.slice(2)); + expect(component.recentlyAccessedCourses).toEqual(courses.slice(0, 2)); + expect(recentCoursesSpy).toHaveBeenCalledOnce(); + }); }); describe('findNextRelevantExercise', () => { diff --git a/src/test/javascript/spec/service/course-access-storage.service.spec.ts b/src/test/javascript/spec/service/course-access-storage.service.spec.ts new file mode 100644 index 000000000000..74a8b765f454 --- /dev/null +++ b/src/test/javascript/spec/service/course-access-storage.service.spec.ts @@ -0,0 +1,41 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { LocalStorageService, NgxWebstorageModule } from 'ngx-webstorage'; +import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; +import { MockLocalStorageService } from '../helpers/mocks/service/mock-local-storage.service'; + +describe('CourseAccessStorageService', () => { + let service: CourseAccessStorageService; + let localStorage: LocalStorageService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NgxWebstorageModule.forRoot()], + providers: [ + CourseAccessStorageService, + { + provide: LocalStorageService, + useClass: MockLocalStorageService, + }, + ], + }); + service = TestBed.inject(CourseAccessStorageService); + localStorage = TestBed.inject(LocalStorageService); + }); + + it('should store accessed course', () => { + const courseId = 123; + service.onCourseAccessed(courseId); + const courseAccessMap = localStorage.retrieve('artemis.courseAccess'); + expect(courseAccessMap).toHaveProperty(courseId.toString()); + }); + + it('should retrieve last accessed courses and remove older courses', fakeAsync(() => { + const courseIds = [123, 456, 789, 101112, 7494]; + courseIds.forEach((courseId) => { + service.onCourseAccessed(courseId); + tick(10); // Wait 10ms to ensure that the timestamp is different for each course + }); + const lastAccessedCourses = service.getLastAccessedCourses(); + expect(lastAccessedCourses).toEqual(courseIds.reverse().slice(0, 3)); + })); +}); From 634feb5bb7cf0987e9e58ee5cda56f12332b823e Mon Sep 17 00:00:00 2001 From: Patrick Bassner Date: Fri, 29 Dec 2023 12:03:01 +0100 Subject: [PATCH 3/5] course overview test --- .../spec/component/course/course-overview.component.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/test/javascript/spec/component/course/course-overview.component.spec.ts b/src/test/javascript/spec/component/course/course-overview.component.spec.ts index 0ac916ecc568..2fa32286925d 100644 --- a/src/test/javascript/spec/component/course/course-overview.component.spec.ts +++ b/src/test/javascript/spec/component/course/course-overview.component.spec.ts @@ -49,6 +49,7 @@ import { CourseStorageService } from 'app/course/manage/course-storage.service'; import { NotificationService } from 'app/shared/notification/notification.service'; import { MockNotificationService } from '../../helpers/mocks/service/mock-notification.service'; import { MockMetisConversationService } from '../../helpers/mocks/service/mock-metis-conversation.service'; +import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; const endDate1 = dayjs().add(1, 'days'); const visibleDate1 = dayjs().subtract(1, 'days'); @@ -120,6 +121,7 @@ describe('CourseOverviewComponent', () => { let tutorialGroupsService: TutorialGroupsService; let tutorialGroupsConfigurationService: TutorialGroupsConfigurationService; let jhiWebsocketService: JhiWebsocketService; + let courseAccessStorageService: CourseAccessStorageService; let router: MockRouter; let jhiWebsocketServiceReceiveStub: jest.SpyInstance; let jhiWebsocketServiceSubscribeSpy: jest.SpyInstance; @@ -169,6 +171,7 @@ describe('CourseOverviewComponent', () => { MockProvider(TutorialGroupsService), MockProvider(TutorialGroupsConfigurationService), MockProvider(MetisConversationService), + MockProvider(CourseAccessStorageService), { provide: Router, useValue: router }, { provide: ActivatedRoute, useValue: route }, { provide: MetisConversationService, useClass: MockMetisConversationService }, @@ -186,6 +189,7 @@ describe('CourseOverviewComponent', () => { tutorialGroupsService = TestBed.inject(TutorialGroupsService); tutorialGroupsConfigurationService = TestBed.inject(TutorialGroupsConfigurationService); jhiWebsocketService = TestBed.inject(JhiWebsocketService); + courseAccessStorageService = TestBed.inject(CourseAccessStorageService); metisConversationService = fixture.debugElement.injector.get(MetisConversationService); jhiWebsocketServiceReceiveStub = jest.spyOn(jhiWebsocketService, 'receive').mockReturnValue(of(quizExercise)); jhiWebsocketServiceSubscribeSpy = jest.spyOn(jhiWebsocketService, 'subscribe'); @@ -211,6 +215,7 @@ describe('CourseOverviewComponent', () => { const getCourseStub = jest.spyOn(courseStorageService, 'getCourse'); const subscribeToTeamAssignmentUpdatesStub = jest.spyOn(component, 'subscribeToTeamAssignmentUpdates'); const subscribeForQuizChangesStub = jest.spyOn(component, 'subscribeForQuizChanges'); + const notifyAboutCourseAccessStub = jest.spyOn(courseAccessStorageService, 'onCourseAccessed'); findOneForDashboardStub.mockReturnValue(of(new HttpResponse({ body: course1, headers: new HttpHeaders() }))); getCourseStub.mockReturnValue(course1); @@ -219,6 +224,7 @@ describe('CourseOverviewComponent', () => { expect(getCourseStub).toHaveBeenCalled(); expect(subscribeForQuizChangesStub).toHaveBeenCalledOnce(); expect(subscribeToTeamAssignmentUpdatesStub).toHaveBeenCalledOnce(); + expect(notifyAboutCourseAccessStub).toHaveBeenCalledExactlyOnceWith(course1.id); }); it('loads conversations when switching to message tab once', async () => { From ddc194d1f9234e90ad6d2cf2b9bc4973b05103f9 Mon Sep 17 00:00:00 2001 From: Patrick Bassner Date: Fri, 29 Dec 2023 12:53:13 +0100 Subject: [PATCH 4/5] trigger review --- .../spec/component/course/course-overview.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/javascript/spec/component/course/course-overview.component.spec.ts b/src/test/javascript/spec/component/course/course-overview.component.spec.ts index 2fa32286925d..491486b412fc 100644 --- a/src/test/javascript/spec/component/course/course-overview.component.spec.ts +++ b/src/test/javascript/spec/component/course/course-overview.component.spec.ts @@ -74,7 +74,7 @@ const courseEmpty: Course = {}; const exam1: Exam = { id: 3, endDate: endDate1, visibleDate: visibleDate1, course: courseEmpty }; const exam2: Exam = { id: 4, course: courseEmpty }; -const exams = [exam1, exam2]; +const exams: Exam[] = [exam1, exam2]; const course1: Course = { id: 1, exams, From 40d30ea253c557f0d8595fb5f234f1d95742d763 Mon Sep 17 00:00:00 2001 From: Patrick Bassner Date: Fri, 29 Dec 2023 13:08:20 +0100 Subject: [PATCH 5/5] fix threshold --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 8f73f77fbd5e..8a44bd317ae1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -102,7 +102,7 @@ module.exports = { // TODO: in the future, the following values should increase to at least 90% statements: 86.7, branches: 73.7, - functions: 80.8, + functions: 80.7, lines: 86.7, }, },