diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseForArchiveDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseForArchiveDTO.java new file mode 100644 index 000000000000..c0b003e668bc --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseForArchiveDTO.java @@ -0,0 +1,16 @@ +package de.tum.cit.aet.artemis.core.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * DTO for representing archived courses from previous semesters. + * + * @param id The id of the course + * @param title The title of the course + * @param semester The semester in which the course was offered + * @param color The background color of the course + * @param icon The icon of the course + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CourseForArchiveDTO(long id, String title, String semester, String color, String icon) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java index fa3bba8a4b73..decece538a7d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java @@ -534,4 +534,30 @@ SELECT COUNT(c) > 0 """) boolean hasLearningPathsEnabled(@Param("courseId") long courseId); + /** + * Retrieves all courses that the user has access to based on their role + * or if they are an admin. Filters out any courses that do not belong to + * a specific semester (i.e., have a null semester). + * + * @param userId The id of the user whose courses are being retrieved + * @param isAdmin A boolean flag indicating whether the user is an admin + * @param now The current time to check if the course is still active + * @return A set of courses that the user has access to and belong to a specific semester + */ + @Query(""" + SELECT DISTINCT c + FROM Course c + LEFT JOIN UserGroup ug ON ug.group IN ( + c.studentGroupName, + c.teachingAssistantGroupName, + c.editorGroupName, + c.instructorGroupName + ) + WHERE (:isAdmin = TRUE OR ug.userId = :userId) + AND c.semester IS NOT NULL + AND c.endDate IS NOT NULL + AND c.endDate < :now + """) + Set findInactiveCoursesForUserRolesWithNonNullSemester(@Param("userId") long userId, @Param("isAdmin") boolean isAdmin, @Param("now") ZonedDateTime now); + } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java index 01bb68edc441..c3d0b51d6fa2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java @@ -661,6 +661,18 @@ public List getAllCoursesForManagementOverview(boolean onlyActive) { return courseRepository.findAllCoursesByManagementGroupNames(userGroups); } + /** + * Retrieves all inactive courses from non-null semesters that the current user is enrolled in + * for the course archive. + * + * @return A list of courses for the course archive. + */ + public Set getAllCoursesForCourseArchive() { + var user = userRepository.getUserWithGroupsAndAuthorities(); + boolean isAdmin = authCheckService.isAdmin(user); + return courseRepository.findInactiveCoursesForUserRolesWithNonNullSemester(user.getId(), isAdmin, ZonedDateTime.now()); + } + /** * Get the active students for these particular exercise ids * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java index 0cb3379e4f99..da9757b1837f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java @@ -72,6 +72,7 @@ import de.tum.cit.aet.artemis.core.config.Constants; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.dto.CourseForArchiveDTO; import de.tum.cit.aet.artemis.core.dto.CourseForDashboardDTO; import de.tum.cit.aet.artemis.core.dto.CourseForImportDTO; import de.tum.cit.aet.artemis.core.dto.CourseManagementDetailViewDTO; @@ -555,6 +556,29 @@ public ResponseEntity> getCoursesForManagementOverview(@RequestPara return ResponseEntity.ok(courseService.getAllCoursesForManagementOverview(onlyActive)); } + /** + * GET /courses/for-archive : get all courses for course archive + * + * @return the ResponseEntity with status 200 (OK) and with body containing + * a set of DTOs, which contain the courses with id, title, semester, color, icon + */ + @GetMapping("courses/for-archive") + @EnforceAtLeastStudent + public ResponseEntity> getCoursesForArchive() { + long start = System.nanoTime(); + User user = userRepository.getUserWithGroupsAndAuthorities(); + log.debug("REST request to get all inactive courses from previous semesters user {} has access to", user.getLogin()); + Set courses = courseService.getAllCoursesForCourseArchive(); + log.debug("courseService.getAllCoursesForCourseArchive done"); + + final Set dto = courses.stream() + .map(course -> new CourseForArchiveDTO(course.getId(), course.getTitle(), course.getSemester(), course.getColor(), course.getCourseIcon())) + .collect(Collectors.toSet()); + + log.debug("GET /courses/for-archive took {} for {} courses for user {}", TimeLogUtil.formatDurationFrom(start), courses.size(), user.getLogin()); + return ResponseEntity.ok(dto); + } + /** * GET /courses/{courseId}/for-enrollment : get a course by id if the course allows enrollment and is currently active. * diff --git a/src/main/webapp/app/course/manage/course-for-archive-dto.ts b/src/main/webapp/app/course/manage/course-for-archive-dto.ts new file mode 100644 index 000000000000..9bce2af6232e --- /dev/null +++ b/src/main/webapp/app/course/manage/course-for-archive-dto.ts @@ -0,0 +1,7 @@ +export class CourseForArchiveDTO { + id: number; + title: string; + semester: string; + color: string; + icon: string; +} diff --git a/src/main/webapp/app/course/manage/course-management.service.ts b/src/main/webapp/app/course/manage/course-management.service.ts index 1db71b31aac3..7aeb99ef0c0b 100644 --- a/src/main/webapp/app/course/manage/course-management.service.ts +++ b/src/main/webapp/app/course/manage/course-management.service.ts @@ -27,6 +27,7 @@ import { ScoresStorageService } from 'app/course/course-scores/scores-storage.se import { CourseStorageService } from 'app/course/manage/course-storage.service'; import { ExerciseType, ScoresPerExerciseType } from 'app/entities/exercise.model'; import { OnlineCourseDtoModel } from 'app/lti/online-course-dto.model'; +import { CourseForArchiveDTO } from './course-for-archive-dto'; export type EntityResponseType = HttpResponse; export type EntityArrayResponseType = HttpResponse; @@ -343,6 +344,13 @@ export class CourseManagementService { ); } + /** + * Find all courses for the archive using a GET request + */ + getCoursesForArchive(): Observable> { + return this.http.get(`${this.resourceUrl}/for-archive`, { observe: 'response' }); + } + /** * returns the exercise details of the courses for the courses' management dashboard * @param onlyActive - if true, only active courses will be considered in the result @@ -703,4 +711,13 @@ export class CourseManagementService { disableCourseOverviewBackground() { this.courseOverviewSubject.next(false); } + + getSemesterCollapseStateFromStorage(storageId: string): boolean { + const storedCollapseState: string | null = localStorage.getItem('semester.collapseState.' + storageId); + return storedCollapseState ? JSON.parse(storedCollapseState) : false; + } + + setSemesterCollapseState(storageId: string, isCollapsed: boolean) { + localStorage.setItem('semester.collapseState.' + storageId, JSON.stringify(isCollapsed)); + } } diff --git a/src/main/webapp/app/overview/course-archive/course-archive.component.html b/src/main/webapp/app/overview/course-archive/course-archive.component.html new file mode 100644 index 000000000000..d88bcdab0bba --- /dev/null +++ b/src/main/webapp/app/overview/course-archive/course-archive.component.html @@ -0,0 +1,70 @@ +@if (courses) { +
+
+
+

+ +
+ @if (courses.length) { +
+ + +
+ } +
+
+
+ @if (courses.length) { +
+ @for (semester of semesters; track semester; let last = $last; let i = $index) { +
+ + +
+ @if (!semesterCollapsed[semester]) { +
+
+ @for (course of coursesBySemester[semester] | searchFilter: ['title'] : searchCourseText; track course) { +
+ +
+ } +
+
+ } + @if (!last) { +
+ } + } +
+ } @else { +
+

+
+ } +
+} diff --git a/src/main/webapp/app/overview/course-archive/course-archive.component.scss b/src/main/webapp/app/overview/course-archive/course-archive.component.scss new file mode 100644 index 000000000000..8f5c6148ae13 --- /dev/null +++ b/src/main/webapp/app/overview/course-archive/course-archive.component.scss @@ -0,0 +1,17 @@ +.course-grid { + display: grid; + // cards can shrink to 325px + grid-template-columns: repeat(auto-fill, minmax(325px, 1fr)); + grid-gap: 1rem; + justify-items: center; +} + +.course-card-wrapper { + width: 100%; + max-width: 400px; +} + +.container-fluid { + // ensure that horizontal spacing in container is consistent + --bs-gutter-x: 2rem; +} diff --git a/src/main/webapp/app/overview/course-archive/course-archive.component.ts b/src/main/webapp/app/overview/course-archive/course-archive.component.ts new file mode 100644 index 000000000000..eb88ee898038 --- /dev/null +++ b/src/main/webapp/app/overview/course-archive/course-archive.component.ts @@ -0,0 +1,151 @@ +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { Course } from 'app/entities/course.model'; +import { CourseManagementService } from '../../course/manage/course-management.service'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { Subscription } from 'rxjs'; +import { faAngleDown, faAngleUp, faArrowDown19, faArrowUp19, faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; +import { SizeProp } from '@fortawesome/fontawesome-svg-core'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { CourseCardHeaderComponent } from '../course-card-header/course-card-header.component'; +import { CourseForArchiveDTO } from 'app/course/manage/course-for-archive-dto'; +import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; + +@Component({ + selector: 'jhi-course-archive', + templateUrl: './course-archive.component.html', + styleUrls: ['./course-archive.component.scss'], + standalone: true, + imports: [ArtemisSharedModule, CourseCardHeaderComponent, SearchFilterComponent], +}) +export class CourseArchiveComponent implements OnInit, OnDestroy { + private archiveCourseSubscription: Subscription; + private courseService = inject(CourseManagementService); + private alertService = inject(AlertService); + + courses: CourseForArchiveDTO[] = []; + semesters: string[]; + fullFormOfSemesterStrings: { [key: string]: string } = {}; + semesterCollapsed: { [key: string]: boolean } = {}; + coursesBySemester: { [key: string]: Course[] } = {}; + searchCourseText = ''; + isSortAscending = true; + iconSize: SizeProp = 'lg'; + + //Icons + readonly faAngleDown = faAngleDown; + readonly faAngleUp = faAngleUp; + readonly faArrowDown19 = faArrowDown19; + readonly faArrowUp19 = faArrowUp19; + readonly faQuestionCircle = faQuestionCircle; + + ngOnInit(): void { + this.loadArchivedCourses(); + this.courseService.enableCourseOverviewBackground(); + } + + /** + * Loads all courses that the student has been enrolled in from previous semesters + */ + loadArchivedCourses(): void { + this.archiveCourseSubscription = this.courseService.getCoursesForArchive().subscribe({ + next: (res: HttpResponse) => { + if (res.body) { + this.courses = res.body || []; + this.courses = this.sortCoursesByTitle(this.courses); + this.semesters = this.getUniqueSemesterNamesSorted(this.courses); + this.mapCoursesIntoSemesters(); + } + }, + error: (error: HttpErrorResponse) => onError(this.alertService, error), + }); + } + + /** + * maps existing courses to each semester + */ + mapCoursesIntoSemesters(): void { + this.semesters.forEach((semester) => { + this.semesterCollapsed[semester] = false; + this.courseService.setSemesterCollapseState(semester, false); + this.coursesBySemester[semester] = this.courses.filter((course) => course.semester === semester); + this.fullFormOfSemesterStrings[semester] = semester.startsWith('WS') ? 'artemisApp.course.archive.winterSemester' : 'artemisApp.course.archive.summerSemester'; + }); + } + + ngOnDestroy(): void { + this.archiveCourseSubscription.unsubscribe(); + this.courseService.disableCourseOverviewBackground(); + } + + setSearchValue(searchValue: string): void { + this.searchCourseText = searchValue; + if (searchValue !== '') { + this.expandOrCollapseBasedOnSearchValue(); + } else { + this.getCollapseStateForSemesters(); + } + } + + onSort(): void { + if (this.semesters) { + this.semesters.reverse(); + this.isSortAscending = !this.isSortAscending; + } + } + /** + * if the searched text is matched with a course title, expand the accordion, otherwise collapse + */ + expandOrCollapseBasedOnSearchValue(): void { + for (const semester of this.semesters) { + const hasMatchingCourse = this.coursesBySemester[semester].some((course) => course.title?.toLowerCase().includes(this.searchCourseText.toLowerCase())); + this.semesterCollapsed[semester] = !hasMatchingCourse; + } + } + + getCollapseStateForSemesters(): void { + for (const semester of this.semesters) { + this.semesterCollapsed[semester] = this.courseService.getSemesterCollapseStateFromStorage(semester); + } + } + + toggleCollapseState(semester: string): void { + this.semesterCollapsed[semester] = !this.semesterCollapsed[semester]; + this.courseService.setSemesterCollapseState(semester, this.semesterCollapsed[semester]); + } + + isCourseFoundInSemester(semester: string): boolean { + return this.coursesBySemester[semester].some((course) => course.title?.toLowerCase().includes(this.searchCourseText.toLowerCase())); + } + + sortCoursesByTitle(courses: CourseForArchiveDTO[]): CourseForArchiveDTO[] { + return courses.sort((courseA, courseB) => (courseA.title ?? '').localeCompare(courseB.title ?? '')); + } + + getUniqueSemesterNamesSorted(courses: CourseForArchiveDTO[]): string[] { + return ( + courses + .map((course) => course.semester ?? '') + // filter down to unique values + .filter((course, index, courses) => courses.indexOf(course) === index) + .sort((semesterA, semesterB) => { + // Parse years in base 10 by extracting the two digits after the WS or SS prefix + const yearsCompared = parseInt(semesterB.slice(2, 4), 10) - parseInt(semesterA.slice(2, 4), 10); + if (yearsCompared !== 0) { + return yearsCompared; + } + + // If years are the same, sort WS over SS + const prefixA = semesterA.slice(0, 2); + const prefixB = semesterB.slice(0, 2); + + if (prefixA === prefixB) { + return 0; // Both semesters are the same (either both WS or both SS) + } + + return prefixA === 'WS' ? -1 : 1; // WS should be placed above SS + }) + ); + } +} diff --git a/src/main/webapp/app/overview/course-card-header/course-card-header.component.html b/src/main/webapp/app/overview/course-card-header/course-card-header.component.html new file mode 100644 index 000000000000..69b6b496fa1e --- /dev/null +++ b/src/main/webapp/app/overview/course-card-header/course-card-header.component.html @@ -0,0 +1,27 @@ +
+
+ @if (courseIcon()) { +
+ +
+ } @else { +
+ {{ courseTitle() | slice: 0 : 1 }} +
+ } +
+
+ {{ courseTitle() }} +
+
+
+ +
+
+
diff --git a/src/main/webapp/app/overview/course-card-header/course-card-header.component.scss b/src/main/webapp/app/overview/course-card-header/course-card-header.component.scss new file mode 100644 index 000000000000..ff73cfff5a6d --- /dev/null +++ b/src/main/webapp/app/overview/course-card-header/course-card-header.component.scss @@ -0,0 +1,58 @@ +.card-header { + // needed, otherwise hover effect won't work due to stretched-link class + z-index: 2; + position: relative; + height: 85px; + opacity: 1; + filter: alpha(opacity = 100); + transition: 0.15s; + background-color: var(--background-color-for-hover) !important; + // inner border radius : outer border radius - outer border thickness (8px - 1px) + border-top-left-radius: 7px; + border-top-right-radius: 7px; + + &:hover { + background-color: color-mix(in srgb, var(--background-color-for-hover), transparent 15%) !important; + } + + .container { + height: 80px; + + .row { + height: 80px; + } + } + + .card-title { + overflow: hidden; + padding-bottom: 1px; + // matches 4 lines + max-height: 76px; + } + + .course-circle { + // same size as the course icons + height: 65px; + min-width: 65px; + background-color: var(--course-image-bg); + border-radius: 50%; + display: inline-block; + color: var(--bs-body-color); + } +} + +.container { + max-width: unset; +} + +jhi-secured-image { + ::ng-deep img { + border-radius: 50%; + height: 65px; + width: auto; + } +} + +.card-header-title { + max-width: 280px; +} diff --git a/src/main/webapp/app/overview/course-card-header/course-card-header.component.ts b/src/main/webapp/app/overview/course-card-header/course-card-header.component.ts new file mode 100644 index 000000000000..3b1bb9b1c433 --- /dev/null +++ b/src/main/webapp/app/overview/course-card-header/course-card-header.component.ts @@ -0,0 +1,27 @@ +import { Component, OnInit, input } from '@angular/core'; +import { CachingStrategy } from 'app/shared/image/secured-image.component'; +import { ARTEMIS_DEFAULT_COLOR } from 'app/app.constants'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; + +@Component({ + selector: 'jhi-course-card-header', + templateUrl: './course-card-header.component.html', + styleUrls: ['./course-card-header.component.scss'], + standalone: true, + imports: [ArtemisSharedModule], +}) +export class CourseCardHeaderComponent implements OnInit { + protected readonly ARTEMIS_DEFAULT_COLOR = ARTEMIS_DEFAULT_COLOR; + courseIcon = input.required(); + courseTitle = input.required(); + courseColor = input.required(); + courseId = input.required(); + archiveMode = input(false); + + CachingStrategy = CachingStrategy; + color: string; + + ngOnInit() { + this.color = this.courseColor() || this.ARTEMIS_DEFAULT_COLOR; + } +} diff --git a/src/main/webapp/app/overview/course-card.component.html b/src/main/webapp/app/overview/course-card.component.html index 4b69401c3d85..51e1df0c089c 100644 --- a/src/main/webapp/app/overview/course-card.component.html +++ b/src/main/webapp/app/overview/course-card.component.html @@ -1,25 +1,5 @@
-
-
- @if (course.courseIcon) { -
- -
- } @else { -
- {{ course.title | slice: 0 : 1 }} -
- } -
-
- {{ course.title }} -
-
-
- -
-
-
+
diff --git a/src/main/webapp/app/overview/course-card.component.ts b/src/main/webapp/app/overview/course-card.component.ts index 043c4a892b48..ce8bbb0bcc7e 100644 --- a/src/main/webapp/app/overview/course-card.component.ts +++ b/src/main/webapp/app/overview/course-card.component.ts @@ -12,11 +12,18 @@ import { ScoresStorageService } from 'app/course/course-scores/scores-storage.se import { ScoreType } from 'app/shared/constants/score-type.constants'; import { CourseScores } from 'app/course/course-scores/course-scores'; import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; +import { CourseCardHeaderComponent } from './course-card-header/course-card-header.component'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { NgxChartsModule, PieChartModule } from '@swimlane/ngx-charts'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { RouterLink } from '@angular/router'; @Component({ selector: 'jhi-overview-course-card', templateUrl: './course-card.component.html', styleUrls: ['course-card.scss'], + standalone: true, + imports: [CourseCardHeaderComponent, ArtemisSharedCommonModule, NgxChartsModule, PieChartModule, TranslateDirective, RouterLink], }) export class CourseCardComponent implements OnChanges { protected readonly faArrowRight = faArrowRight; @@ -80,8 +87,6 @@ export class CourseCardComponent implements OnChanges { this.ngxDoughnutData[1].value = scoreNotReached; this.ngxDoughnutData = [...this.ngxDoughnutData]; } - - this.courseColor = this.course.color || this.ARTEMIS_DEFAULT_COLOR; } /** diff --git a/src/main/webapp/app/overview/course-card.scss b/src/main/webapp/app/overview/course-card.scss index 35d910731e5f..6525f3881181 100644 --- a/src/main/webapp/app/overview/course-card.scss +++ b/src/main/webapp/app/overview/course-card.scss @@ -10,49 +10,6 @@ background-color: var(--hover-slightly-darker-body-bg); } - .card-header { - // needed, otherwise hover effect won't work due to stretched-link class - z-index: 2; - position: relative; - height: 85px; - opacity: 1; - filter: alpha(opacity = 100); - transition: 0.15s; - background-color: var(--background-color-for-hover) !important; - // inner border radius : outer border radius - outer border thickness (8px - 1px) - border-top-left-radius: 7px; - border-top-right-radius: 7px; - - &:hover { - background-color: color-mix(in srgb, var(--background-color-for-hover), transparent 15%) !important; - } - - .container { - height: 80px; - - .row { - height: 80px; - } - } - - .card-title { - overflow: hidden; - padding-bottom: 1px; - // matches 4 lines - max-height: 76px; - } - - .course-circle { - // same size as the course icons - height: 65px; - min-width: 65px; - background-color: var(--course-image-bg); - border-radius: 50%; - display: inline-block; - color: var(--bs-body-color); - } - } - .card-body { .information-box-wrapper { height: 135px; diff --git a/src/main/webapp/app/overview/course-overview.component.ts b/src/main/webapp/app/overview/course-overview.component.ts index 86d8e31f26d3..f2ddf5708172 100644 --- a/src/main/webapp/app/overview/course-overview.component.ts +++ b/src/main/webapp/app/overview/course-overview.component.ts @@ -220,16 +220,20 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit this.course = this.courseStorageService.getCourse(this.courseId); this.isNotManagementView = !this.router.url.startsWith('/course-management'); // Notify the course access storage service that the course has been accessed - this.courseAccessStorageService.onCourseAccessed( - this.courseId, - CourseAccessStorageService.STORAGE_KEY, - CourseAccessStorageService.MAX_DISPLAYED_RECENTLY_ACCESSED_COURSES_OVERVIEW, - ); - this.courseAccessStorageService.onCourseAccessed( - this.courseId, - CourseAccessStorageService.STORAGE_KEY_DROPDOWN, - CourseAccessStorageService.MAX_DISPLAYED_RECENTLY_ACCESSED_COURSES_DROPDOWN, - ); + // If course is not active, it means that it is accessed from course archive, which should not + // be stored in local storage and therefore displayed in recently accessed + if (this.course && this.isCourseActive(this.course)) { + this.courseAccessStorageService.onCourseAccessed( + this.courseId, + CourseAccessStorageService.STORAGE_KEY, + CourseAccessStorageService.MAX_DISPLAYED_RECENTLY_ACCESSED_COURSES_OVERVIEW, + ); + this.courseAccessStorageService.onCourseAccessed( + this.courseId, + CourseAccessStorageService.STORAGE_KEY_DROPDOWN, + CourseAccessStorageService.MAX_DISPLAYED_RECENTLY_ACCESSED_COURSES_DROPDOWN, + ); + } await firstValueFrom(this.loadCourse()); await this.initAfterCourseLoad(); @@ -827,4 +831,15 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit this.isNavbarCollapsed = !this.isNavbarCollapsed; localStorage.setItem('navbar.collapseState', JSON.stringify(this.isNavbarCollapsed)); } + + /** + * A course is active if the end date is after the current date or + * end date is not set at all + * + * @param course The given course to be checked if it is active + * @returns true if the course is active, otherwise false + */ + isCourseActive(course: Course): boolean { + return course.endDate ? dayjs(course.endDate).isAfter(dayjs()) : true; + } } diff --git a/src/main/webapp/app/overview/courses-routing.module.ts b/src/main/webapp/app/overview/courses-routing.module.ts index 4cb31090febf..b40c8c952f85 100644 --- a/src/main/webapp/app/overview/courses-routing.module.ts +++ b/src/main/webapp/app/overview/courses-routing.module.ts @@ -12,6 +12,7 @@ import { CourseTutorialGroupDetailComponent } from './tutorial-group-details/cou import { ExamParticipationComponent } from 'app/exam/participate/exam-participation.component'; import { PendingChangesGuard } from 'app/shared/guard/pending-changes.guard'; import { CourseDashboardGuard } from 'app/overview/course-dashboard/course-dashboard-guard.service'; +import { CourseArchiveComponent } from './course-archive/course-archive.component'; const routes: Routes = [ { @@ -27,6 +28,15 @@ const routes: Routes = [ path: 'enroll', loadChildren: () => import('./course-registration/course-registration.module').then((m) => m.CourseRegistrationModule), }, + { + path: 'archive', + component: CourseArchiveComponent, + data: { + authorities: [Authority.USER], + pageTitle: 'overview.archive', + }, + canActivate: [UserRouteAccessService], + }, // /courses/:courseId/register is special, // because we won't have access to the course object before the user is registered, // so we need to load it outside the normal course routing diff --git a/src/main/webapp/app/overview/courses.component.html b/src/main/webapp/app/overview/courses.component.html index 5836c04911c3..b5dabf3a549c 100644 --- a/src/main/webapp/app/overview/courses.component.html +++ b/src/main/webapp/app/overview/courses.component.html @@ -24,7 +24,7 @@

{{ nextRelevantExam.title }}

{{ 'artemisApp.studentDashboard.title' | artemisTranslate }} ({{ regularCourses.length + recentlyAccessedCourses.length }})

- + @@ -62,6 +62,13 @@

}

+@if (coursesLoaded) { +
+
+
 
+ +
+} @if ((courses | searchFilter: ['title'] : searchCourseText).length > 0) { diff --git a/src/main/webapp/app/overview/courses.module.ts b/src/main/webapp/app/overview/courses.module.ts index 5dbae0d30fde..9d6693a17fdc 100644 --- a/src/main/webapp/app/overview/courses.module.ts +++ b/src/main/webapp/app/overview/courses.module.ts @@ -37,16 +37,9 @@ import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.co NgxChartsModule, PieChartModule, ArtemisSidebarModule, - SearchFilterComponent, - ], - declarations: [ - CoursesComponent, - CourseOverviewComponent, CourseCardComponent, - CourseExercisesComponent, - CourseLecturesComponent, - CourseLectureRowComponent, - CourseUnenrollmentModalComponent, + SearchFilterComponent, ], + declarations: [CoursesComponent, CourseOverviewComponent, CourseExercisesComponent, CourseLecturesComponent, CourseLectureRowComponent, CourseUnenrollmentModalComponent], }) export class ArtemisCoursesModule {} diff --git a/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts b/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts index 14cc8732f244..af2c4cc17437 100644 --- a/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts +++ b/src/main/webapp/app/shared/layouts/navbar/navbar.component.ts @@ -384,6 +384,7 @@ export class NavbarComponent implements OnInit, OnDestroy { live: 'artemisApp.submission.detail.title', courses: 'artemisApp.course.home.title', enroll: 'artemisApp.studentDashboard.enroll.title', + archive: 'artemisApp.course.archive.title', }; /** diff --git a/src/main/webapp/i18n/de/course.json b/src/main/webapp/i18n/de/course.json index 155b173b50bc..88eea71e6689 100644 --- a/src/main/webapp/i18n/de/course.json +++ b/src/main/webapp/i18n/de/course.json @@ -15,6 +15,14 @@ "typeNameToConfirm": "Bitte gib den Namen des Kurses zur Bestätigung ein.", "icon": "Kursicon löschen" }, + "archive": { + "title": "Archiv", + "sort": "Sortieren", + "tip": "Das Archiv ermöglicht dir, all deine vergangenen Kurse, organisiert nach Semestern, anzusehen. Klicke auf ein Semester, um es zu erweitern und die Kurse zu sehen, in die du in diesem Zeitraum eingeschrieben warst.", + "noCoursesPreviousSemester": "Keine Kurse aus früheren Semestern gefunden", + "winterSemester": "Wintersemester 20{{ param }}", + "summerSemester": "Sommersemester 20{{ param }}" + }, "showActive": "Nur aktive Kurse anzeigen", "totalScore": "Gesamtergebnis:", "title": "Titel", diff --git a/src/main/webapp/i18n/de/global.json b/src/main/webapp/i18n/de/global.json index b809dec61943..42d380081c11 100644 --- a/src/main/webapp/i18n/de/global.json +++ b/src/main/webapp/i18n/de/global.json @@ -350,6 +350,7 @@ "statistics": "Kursstatistiken", "exams": "Klausuren", "communication": "Kommunikation", + "archive": "Kursarchiv", "faq": "FAQ" }, "connectionStatus": { diff --git a/src/main/webapp/i18n/de/student-dashboard.json b/src/main/webapp/i18n/de/student-dashboard.json index 5906220b4d8a..e44231aee83a 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -12,10 +12,14 @@ "cardTitle": "Deine insgesamte Punktzahl:", "noStatistics": "Keine Statistik verfügbar", "cardNoExerciseLabel": "Keine Übung geplant", - "cardExerciseLabel": "Nächste Übung", + "cardExerciseLabel": "Nächste Übung:", "points": "{{ totalAbsoluteScore }} / {{ totalReachableScore }} Punkte", "cardScore": "Punktzahl", "cardManageCourse": "Kurs Verwalten", + "archive": { + "oldCourses": "Suchst du nach alten Kursen? Klicke", + "here": "hier" + }, "noCoursesFound": "Keine Kurse gefunden", "enroll": { "title": "Kurseinschreibung", diff --git a/src/main/webapp/i18n/en/course.json b/src/main/webapp/i18n/en/course.json index f2186bb72c16..5613d4e1b02b 100644 --- a/src/main/webapp/i18n/en/course.json +++ b/src/main/webapp/i18n/en/course.json @@ -15,6 +15,14 @@ "typeNameToConfirm": "Please type in the name of the course to confirm.", "icon": "Remove Icon" }, + "archive": { + "title": "Archive", + "sort": "Sort", + "tip": "The archive enables you to view all your past courses, organized by semester. Click on a semester to expand and see the courses you were enrolled in during that period.", + "noCoursesPreviousSemester": "No courses found from previous semesters", + "winterSemester": "Winter semester 20{{ param }}", + "summerSemester": "Summer semester 20{{ param }}" + }, "showActive": "Show only active courses", "totalScore": "Total Score:", "title": "Title", diff --git a/src/main/webapp/i18n/en/global.json b/src/main/webapp/i18n/en/global.json index 623c55bb042b..fa014d6128ef 100644 --- a/src/main/webapp/i18n/en/global.json +++ b/src/main/webapp/i18n/en/global.json @@ -352,6 +352,7 @@ "statistics": "Course statistics", "exams": "Exams", "communication": "Communication", + "archive": "Course archive", "faq": "FAQ" }, "connectionStatus": { diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index b9eddb4ecfa3..cf86067f007b 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -12,10 +12,14 @@ "cardTitle": "Your overall points:", "noStatistics": "No statistics available", "cardNoExerciseLabel": "No exercise planned", - "cardExerciseLabel": "Next Exercise", + "cardExerciseLabel": "Next Exercise:", "points": "{{ totalAbsoluteScore }} / {{ totalReachableScore }} Points", "cardScore": "Score", "cardManageCourse": "Manage Course", + "archive": { + "oldCourses": "Looking for old courses? Click", + "here": "here" + }, "noCoursesFound": "No Courses Found", "enroll": { "title": "Course Enrollment", diff --git a/src/test/java/de/tum/cit/aet/artemis/core/util/CourseTestService.java b/src/test/java/de/tum/cit/aet/artemis/core/util/CourseTestService.java index 3b8ce269954b..557dddb86b74 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/util/CourseTestService.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/util/CourseTestService.java @@ -90,6 +90,7 @@ import de.tum.cit.aet.artemis.core.domain.CourseInformationSharingConfiguration; import de.tum.cit.aet.artemis.core.domain.Organization; import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.dto.CourseForArchiveDTO; import de.tum.cit.aet.artemis.core.dto.CourseForDashboardDTO; import de.tum.cit.aet.artemis.core.dto.CourseForImportDTO; import de.tum.cit.aet.artemis.core.dto.CourseManagementDetailViewDTO; @@ -3384,4 +3385,58 @@ public void testGetCoursesForImport() throws Exception { assertThat(found).as("Course is available").isPresent(); } } + + // Test + public void testGetAllCoursesForCourseArchiveWithNonNullSemestersAndEndDate() throws Exception { + List expectedOldCourses = new ArrayList<>(); + for (int i = 1; i <= 4; i++) { + expectedOldCourses.add(courseUtilService.createCourse((long) i)); + } + + expectedOldCourses.get(0).setSemester("SS20"); + expectedOldCourses.get(0).setEndDate(ZonedDateTime.now().minusDays(10)); + expectedOldCourses.get(1).setSemester("SS21"); + expectedOldCourses.get(1).setEndDate(ZonedDateTime.now().minusDays(10)); + expectedOldCourses.get(2).setSemester("WS21/22"); + expectedOldCourses.get(2).setEndDate(ZonedDateTime.now().minusDays(10)); + expectedOldCourses.get(3).setSemester(null); // will be filtered out + + for (Course oldCourse : expectedOldCourses) { + courseRepo.save(oldCourse); + } + + final Set actualOldCourses = request.getSet("/api/courses/for-archive", HttpStatus.OK, CourseForArchiveDTO.class); + assertThat(actualOldCourses).as("Course archive has 3 courses").hasSize(3); + assertThat(actualOldCourses).as("Course archive has the correct semesters").extracting("semester").containsExactlyInAnyOrder(expectedOldCourses.get(0).getSemester(), + expectedOldCourses.get(1).getSemester(), expectedOldCourses.get(2).getSemester()); + assertThat(actualOldCourses).as("Course archive got the correct courses").extracting("id").containsExactlyInAnyOrder(expectedOldCourses.get(0).getId(), + expectedOldCourses.get(1).getId(), expectedOldCourses.get(2).getId()); + Optional notFound = actualOldCourses.stream().filter(c -> Objects.equals(c.id(), expectedOldCourses.get(3).getId())).findFirst(); + assertThat(notFound).as("Course archive did not fetch the last course").isNotPresent(); + } + + // Test + public void testGetAllCoursesForCourseArchiveForUnenrolledStudent() throws Exception { + Course course1 = courseUtilService.createCourse((long) 1); + course1.setSemester("SS20"); + course1.setEndDate(ZonedDateTime.now().minusDays(10)); + courseRepo.save(course1); + + Course course2 = courseUtilService.createCourse((long) 2); + course2.setSemester("SS21"); + course2.setEndDate(ZonedDateTime.now().minusDays(10)); + courseRepo.save(course2); + + Course course3 = courseUtilService.createCourse((long) 3); + course3.setSemester("WS21/22"); + course3.setEndDate(ZonedDateTime.now().minusDays(10)); + courseRepo.save(course3); + + // remove student from all courses + removeAllGroupsFromStudent1(); + + final Set actualCoursesForStudent = request.getSet("/api/courses/for-archive", HttpStatus.OK, CourseForArchiveDTO.class); + assertThat(actualCoursesForStudent).as("Course archive does not show any courses to the user removed from these courses").hasSize(0); + } + } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/CourseGitlabJenkinsIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/CourseGitlabJenkinsIntegrationTest.java index f23887c80d43..f4fe9068c334 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/CourseGitlabJenkinsIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/CourseGitlabJenkinsIntegrationTest.java @@ -1060,4 +1060,16 @@ void testGetCoursesForImport_asAdmin() throws Exception { void testFindAllOnlineCoursesForLtiDashboard() throws Exception { courseTestService.testFindAllOnlineCoursesForLtiDashboard(); } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetAllCoursesForCourseArchiveWithNonNullSemesters() throws Exception { + courseTestService.testGetAllCoursesForCourseArchiveWithNonNullSemestersAndEndDate(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetAllCoursesForCourseArchiveForUnenrolledStudent() throws Exception { + courseTestService.testGetAllCoursesForCourseArchiveForUnenrolledStudent(); + } } diff --git a/src/test/javascript/spec/component/course/course-archive.component.spec.ts b/src/test/javascript/spec/component/course/course-archive.component.spec.ts new file mode 100644 index 000000000000..8bc41d3e81b6 --- /dev/null +++ b/src/test/javascript/spec/component/course/course-archive.component.spec.ts @@ -0,0 +1,277 @@ +import { HttpHeaders, HttpResponse } from '@angular/common/http'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; +import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; +import { MockHasAnyAuthorityDirective } from '../../helpers/mocks/directive/mock-has-any-authority.directive'; +import { MockSyncStorage } from '../../helpers/mocks/service/mock-sync-storage.service'; +import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; +import { ArtemisTestModule } from '../../test.module'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { SortByDirective } from 'app/shared/sort/sort-by.directive'; +import { SortDirective } from 'app/shared/sort/sort.directive'; +import { of } from 'rxjs'; +import { By } from '@angular/platform-browser'; +import { CourseArchiveComponent } from 'app/overview/course-archive/course-archive.component'; +import { CourseCardHeaderComponent } from 'app/overview/course-card-header/course-card-header.component'; +import { SearchFilterPipe } from 'app/shared/pipes/search-filter.pipe'; +import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; +import { CourseForArchiveDTO } from 'app/course/manage/course-for-archive-dto'; + +const course1 = { id: 1, semester: 'WS21/22', title: 'iPraktikum' } as CourseForArchiveDTO; +const course2 = { id: 2, semester: 'WS21/22' } as CourseForArchiveDTO; +const course3 = { id: 3, semester: 'SS22' } as CourseForArchiveDTO; +const course4 = { id: 4, semester: 'SS22' } as CourseForArchiveDTO; +const course5 = { id: 5, semester: 'WS23/24' } as CourseForArchiveDTO; +const course6 = { id: 6, semester: 'SS19' } as CourseForArchiveDTO; +const course7 = { id: 7, semester: 'WS22/23' } as CourseForArchiveDTO; +const courses: CourseForArchiveDTO[] = [course1, course2, course3, course4, course5, course6, course7]; + +describe('CourseArchiveComponent', () => { + let component: CourseArchiveComponent; + let fixture: ComponentFixture; + let courseService: CourseManagementService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + declarations: [ + CourseArchiveComponent, + SearchFilterPipe, + SearchFilterComponent, + MockDirective(MockHasAnyAuthorityDirective), + MockPipe(ArtemisTranslatePipe), + MockDirective(SortDirective), + MockDirective(SortByDirective), + MockPipe(ArtemisDatePipe), + MockComponent(CourseCardHeaderComponent), + ], + providers: [ + { provide: LocalStorageService, useClass: MockSyncStorage }, + { provide: SessionStorageService, useClass: MockSyncStorage }, + { provide: TranslateService, useClass: MockTranslateService }, + ], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(CourseArchiveComponent); + component = fixture.componentInstance; + courseService = TestBed.inject(CourseManagementService); + httpMock = TestBed.inject(HttpTestingController); + fixture.detectChanges(); + }); + }); + + afterEach(() => { + component.ngOnDestroy(); + jest.restoreAllMocks(); + }); + + describe('onInit', () => { + it('should call loadArchivedCourses on init', () => { + const loadArchivedCoursesSpy = jest.spyOn(component, 'loadArchivedCourses'); + + component.ngOnInit(); + + expect(loadArchivedCoursesSpy).toHaveBeenCalledOnce(); + }); + + it('should load archived courses on init', () => { + const getCoursesForArchiveSpy = jest.spyOn(courseService, 'getCoursesForArchive'); + getCoursesForArchiveSpy.mockReturnValue(of(new HttpResponse({ body: courses, headers: new HttpHeaders() }))); + + component.ngOnInit(); + + expect(component.courses).toEqual(courses); + expect(component.courses).toHaveLength(7); + }); + + it('should handle an empty response body correctly when fetching all courses for archive', () => { + const emptyCourses: CourseForArchiveDTO[] = []; + const getCoursesForArchiveSpy = jest.spyOn(courseService, 'getCoursesForArchive'); + + const req = httpMock.expectOne({ method: 'GET', url: `api/courses/for-archive` }); + component.ngOnInit(); + + expect(getCoursesForArchiveSpy).toHaveBeenCalledOnce(); + req.flush(null); + expect(component.courses).toStrictEqual(emptyCourses); + }); + + it('should sort the name of the semesters uniquely', () => { + const getCoursesForArchiveSpy = jest.spyOn(courseService, 'getCoursesForArchive'); + getCoursesForArchiveSpy.mockReturnValue(of(new HttpResponse({ body: courses, headers: new HttpHeaders() }))); + component.ngOnInit(); + + expect(getCoursesForArchiveSpy).toHaveBeenCalledOnce(); + + expect(component.semesters).toHaveLength(5); + expect(component.semesters[0]).toBe('WS23/24'); + expect(component.semesters[1]).toBe('WS22/23'); + expect(component.semesters[2]).toBe('SS22'); + expect(component.semesters[3]).toBe('WS21/22'); + expect(component.semesters[4]).toBe('SS19'); + }); + + it('should map courses into semesters', () => { + const getCoursesForArchiveSpy = jest.spyOn(courseService, 'getCoursesForArchive'); + getCoursesForArchiveSpy.mockReturnValue(of(new HttpResponse({ body: courses, headers: new HttpHeaders() }))); + const mapCoursesIntoSemestersSpy = jest.spyOn(component, 'mapCoursesIntoSemesters'); + component.ngOnInit(); + + expect(getCoursesForArchiveSpy).toHaveBeenCalledOnce(); + expect(mapCoursesIntoSemestersSpy).toHaveBeenCalledOnce(); + + expect(component.coursesBySemester).toStrictEqual({ + 'WS23/24': [course5], + 'WS22/23': [course7], + SS22: [course3, course4], + 'WS21/22': [course2, course1], + SS19: [course6], + }); + }); + + it('should initialize collapse state of semesters correctly', () => { + const getCoursesForArchiveSpy = jest.spyOn(courseService, 'getCoursesForArchive'); + getCoursesForArchiveSpy.mockReturnValue(of(new HttpResponse({ body: courses, headers: new HttpHeaders() }))); + const mapCoursesIntoSemestersSpy = jest.spyOn(component, 'mapCoursesIntoSemesters'); + component.ngOnInit(); + + expect(getCoursesForArchiveSpy).toHaveBeenCalledOnce(); + expect(mapCoursesIntoSemestersSpy).toHaveBeenCalledOnce(); + + // we expand all semesters at first + expect(component.semesterCollapsed).toStrictEqual({ + 'WS23/24': false, + 'WS22/23': false, + SS22: false, + 'WS21/22': false, + SS19: false, + }); + }); + + it('should initialize translate of semesters correctly', () => { + const getCoursesForArchiveSpy = jest.spyOn(courseService, 'getCoursesForArchive'); + getCoursesForArchiveSpy.mockReturnValue(of(new HttpResponse({ body: courses, headers: new HttpHeaders() }))); + const mapCoursesIntoSemestersSpy = jest.spyOn(component, 'mapCoursesIntoSemesters'); + component.ngOnInit(); + + expect(getCoursesForArchiveSpy).toHaveBeenCalledOnce(); + expect(mapCoursesIntoSemestersSpy).toHaveBeenCalledOnce(); + + expect(component.fullFormOfSemesterStrings).toStrictEqual({ + 'WS23/24': 'artemisApp.course.archive.winterSemester', + 'WS22/23': 'artemisApp.course.archive.winterSemester', + SS22: 'artemisApp.course.archive.summerSemester', + 'WS21/22': 'artemisApp.course.archive.winterSemester', + SS19: 'artemisApp.course.archive.summerSemester', + }); + }); + + it('should collapse semester groups based on the search value correctly', () => { + const getCoursesForArchiveSpy = jest.spyOn(courseService, 'getCoursesForArchive'); + getCoursesForArchiveSpy.mockReturnValue(of(new HttpResponse({ body: courses, headers: new HttpHeaders() }))); + const mapCoursesIntoSemestersSpy = jest.spyOn(component, 'mapCoursesIntoSemesters'); + component.ngOnInit(); + + expect(getCoursesForArchiveSpy).toHaveBeenCalledOnce(); + expect(mapCoursesIntoSemestersSpy).toHaveBeenCalledOnce(); + + const expandOrCollapseBasedOnSearchValueSpy = jest.spyOn(component, 'expandOrCollapseBasedOnSearchValue'); + component.setSearchValue('iPraktikum'); + + expect(expandOrCollapseBasedOnSearchValueSpy).toHaveBeenCalledOnce(); + // Every semester accordion should be collapsed except WS21/22, because iPraktikum is in semester WS21/22 + expect(component.semesterCollapsed).toStrictEqual({ + 'WS23/24': true, + 'WS22/23': true, + SS22: true, + 'WS21/22': false, + SS19: true, + }); + }); + + it('should toggle sort order and update the icon accordingly', fakeAsync(() => { + const getCoursesForArchiveSpy = jest.spyOn(courseService, 'getCoursesForArchive'); + getCoursesForArchiveSpy.mockReturnValue(of(new HttpResponse({ body: courses, headers: new HttpHeaders() }))); + const mapCoursesIntoSemestersSpy = jest.spyOn(component, 'mapCoursesIntoSemesters'); + component.ngOnInit(); + fixture.detectChanges(); + tick(); + + expect(getCoursesForArchiveSpy).toHaveBeenCalledOnce(); + expect(mapCoursesIntoSemestersSpy).toHaveBeenCalledOnce(); + expect(component.courses).toBeDefined(); + expect(component.courses).toHaveLength(7); + + const onSortSpy = jest.spyOn(component, 'onSort'); + const button = fixture.debugElement.nativeElement.querySelector('#sort-test'); + + expect(button).not.toBeNull(); + button.click(); + fixture.detectChanges(); + + expect(onSortSpy).toHaveBeenCalled(); + expect(component.isSortAscending).toBeFalse(); + expect(component.semesters[4]).toBe('WS23/24'); + expect(component.semesters[3]).toBe('WS22/23'); + expect(component.semesters[2]).toBe('SS22'); + expect(component.semesters[1]).toBe('WS21/22'); + expect(component.semesters[0]).toBe('SS19'); + + const iconComponent = fixture.debugElement.query(By.css('#icon-test-down')).componentInstance; + + expect(iconComponent).not.toBeNull(); + expect(iconComponent.icon).toBe(component.faArrowUp19); + })); + + it('should find the correct course and call toggle', fakeAsync(() => { + const getCoursesForArchiveSpy = jest.spyOn(courseService, 'getCoursesForArchive'); + getCoursesForArchiveSpy.mockReturnValue(of(new HttpResponse({ body: courses, headers: new HttpHeaders() }))); + const mapCoursesIntoSemestersSpy = jest.spyOn(component, 'mapCoursesIntoSemesters'); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + expect(component.courses).toHaveLength(7); + expect(getCoursesForArchiveSpy).toHaveBeenCalledOnce(); + expect(mapCoursesIntoSemestersSpy).toHaveBeenCalledOnce(); + + // iPraktikum is in semester-group-3 : WS21/22 + const button = fixture.debugElement.nativeElement.querySelector('#semester-group-3'); + const toggleCollapseStateSpy = jest.spyOn(component, 'toggleCollapseState'); + component.setSearchValue('iPraktikum'); + const courseFound = component.isCourseFoundInSemester('WS21/22'); + expect(courseFound).toBeTrue(); + expect(button).not.toBeNull(); + button.click(); + expect(toggleCollapseStateSpy).toHaveBeenCalledOnce(); + })); + + it('should initialize collapse state correctly', () => { + const getCoursesForArchiveSpy = jest.spyOn(courseService, 'getCoursesForArchive'); + getCoursesForArchiveSpy.mockReturnValue(of(new HttpResponse({ body: courses, headers: new HttpHeaders() }))); + const mapCoursesIntoSemestersSpy = jest.spyOn(component, 'mapCoursesIntoSemesters'); + + component.ngOnInit(); + expect(component.courses).toHaveLength(7); + expect(getCoursesForArchiveSpy).toHaveBeenCalledOnce(); + expect(mapCoursesIntoSemestersSpy).toHaveBeenCalledOnce(); + const getCollapseStateForSemestersSpy = jest.spyOn(component, 'getCollapseStateForSemesters'); + component.setSearchValue(''); + expect(getCollapseStateForSemestersSpy).toHaveBeenCalledOnce(); + + expect(component.semesterCollapsed).toStrictEqual({ + 'WS23/24': false, + 'WS22/23': false, + SS22: false, + 'WS21/22': false, + SS19: false, + }); + }); + }); +}); 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 8a92520473bf..ab342d14828c 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 @@ -13,7 +13,6 @@ import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; import { CourseExerciseRowComponent } from 'app/overview/course-exercises/course-exercise-row.component'; import { CourseExercisesComponent } from 'app/overview/course-exercises/course-exercises.component'; import { CourseRegistrationComponent } from 'app/overview/course-registration/course-registration.component'; -import { CourseCardComponent } from 'app/overview/course-card.component'; import dayjs from 'dayjs/esm'; import { Exercise } from 'app/entities/exercise.model'; import { DueDateStat } from 'app/course/dashboards/due-date-stat.model'; @@ -184,7 +183,6 @@ describe('CourseOverviewComponent', () => { MockComponent(CourseExerciseRowComponent), MockComponent(CourseExercisesComponent), MockComponent(CourseRegistrationComponent), - MockComponent(CourseCardComponent), MockComponent(SecuredImageComponent), ], providers: [ 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 925a54864297..2c01e4fc0273 100644 --- a/src/test/javascript/spec/component/course/course.component.spec.ts +++ b/src/test/javascript/spec/component/course/course.component.spec.ts @@ -274,4 +274,26 @@ describe('CoursesComponent', () => { expect(component.courses).toEqual([course1, course2, course6]); expect(component.nextRelevantExams).toEqual([]); })); + + it('should initialize search course text correctly', () => { + const searchedCourse = 'Test Course'; + component.setSearchValue('Test Course'); + expect(searchedCourse).toBe(component.searchCourseText); + }); + + it('should adjust sort direction by clicking on sort icon', () => { + const findAllForDashboardSpy = jest.spyOn(courseService, 'findAllForDashboard'); + findAllForDashboardSpy.mockReturnValue(of(new HttpResponse({ body: coursesDashboard, headers: new HttpHeaders() }))); + component.ngOnInit(); + + expect(findAllForDashboardSpy).toHaveBeenCalledOnce(); + expect(component.courses).toEqual(courses); + expect(component.isSortAscending).toBeTrue(); + + const onSortSpy = jest.spyOn(component, 'onSort'); + const button = fixture.debugElement.nativeElement.querySelector('#test-sort'); + button.click(); + expect(onSortSpy).toHaveBeenCalledOnce(); + expect(component.isSortAscending).toBeFalse(); + }); }); diff --git a/src/test/javascript/spec/component/overview/course-card.component.spec.ts b/src/test/javascript/spec/component/overview/course-card.component.spec.ts index fbe16f160c51..bf2403aecf23 100644 --- a/src/test/javascript/spec/component/overview/course-card.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-card.component.spec.ts @@ -15,6 +15,7 @@ import { PieChartModule } from '@swimlane/ngx-charts'; import { TranslateDirective } from 'app/shared/language/translate.directive'; import { ScoresStorageService } from 'app/course/course-scores/scores-storage.service'; import { CourseScores } from 'app/course/course-scores/course-scores'; +import { CourseCardHeaderComponent } from 'app/overview/course-card-header/course-card-header.component'; describe('CourseCardComponent', () => { let fixture: ComponentFixture; @@ -40,6 +41,7 @@ describe('CourseCardComponent', () => { MockPipe(ArtemisTimeAgoPipe), MockRouterLinkDirective, MockComponent(SecuredImageComponent), + MockComponent(CourseCardHeaderComponent), MockDirective(TranslateDirective), ], }) diff --git a/src/test/javascript/spec/integration/guided-tour/guided-tour.integration.spec.ts b/src/test/javascript/spec/integration/guided-tour/guided-tour.integration.spec.ts index 3ebe67f4a26e..d439a5cb2347 100644 --- a/src/test/javascript/spec/integration/guided-tour/guided-tour.integration.spec.ts +++ b/src/test/javascript/spec/integration/guided-tour/guided-tour.integration.spec.ts @@ -20,6 +20,7 @@ import { ThemeSwitchComponent } from 'app/core/theme/theme-switch.component'; import { User } from 'app/core/user/user.model'; import { MockHasAnyAuthorityDirective } from '../../helpers/mocks/directive/mock-has-any-authority.directive'; import { CourseCardComponent } from 'app/overview/course-card.component'; +import { CourseCardHeaderComponent } from 'app/overview/course-card-header/course-card-header.component'; import { Course } from 'app/entities/course.model'; import { ARTEMIS_DEFAULT_COLOR } from 'app/app.constants'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; @@ -79,6 +80,7 @@ describe('Guided tour integration', () => { FooterComponent, NotificationSidebarComponent, MockHasAnyAuthorityDirective, + MockComponent(CourseCardHeaderComponent), MockComponent(CourseRegistrationComponent), MockComponent(CourseExerciseRowComponent), MockComponent(LoadingNotificationComponent),