diff --git a/src/main/java/de/tum/in/www1/artemis/repository/LearningPathRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/LearningPathRepository.java index b64e57bad3b2..348a6988c64e 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/LearningPathRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/LearningPathRepository.java @@ -24,6 +24,13 @@ default LearningPath findByCourseIdAndUserIdElseThrow(long courseId, long userId return findByCourseIdAndUserId(courseId, userId).orElseThrow(() -> new EntityNotFoundException("LearningPath")); } + @EntityGraph(type = LOAD, attributePaths = { "user" }) + Optional findWithEagerUserById(long learningPathId); + + default LearningPath findWithEagerUserByIdElseThrow(long learningPathId) { + return findWithEagerUserById(learningPathId).orElseThrow(() -> new EntityNotFoundException("LearningPath")); + } + @EntityGraph(type = LOAD, attributePaths = { "competencies" }) Optional findWithEagerCompetenciesByCourseIdAndUserId(long courseId, long userId); @@ -45,20 +52,31 @@ SELECT COUNT (learningPath) """) long countLearningPathsOfEnrolledStudentsInCourse(@Param("courseId") long courseId); + /** + * Gets a learning path with eagerly fetched competencies, linked lecture units and exercises, and the corresponding domain objects storing the progress. + *

+ * The query only fetches data related to the owner of the learning path. participations and progress for other users are not included. + * IMPORTANT: JPA doesn't support JOIN-FETCH-ON statements. To fetch the relevant data we utilize the entity graph annotation. + * Moving the ON clauses to the WHERE clause would result in significantly different and faulty output. + * + * @param learningPathId the id of the learning path to fetch + * @return the learning path with fetched data + */ @Query(""" SELECT learningPath FROM LearningPath learningPath LEFT JOIN FETCH learningPath.competencies competencies - LEFT JOIN FETCH competencies.userProgress progress + LEFT JOIN competencies.userProgress progress + ON competencies.id = progress.learningGoal.id AND progress.user.id = learningPath.user.id LEFT JOIN FETCH competencies.lectureUnits lectureUnits - LEFT JOIN FETCH lectureUnits.completedUsers completedUsers + LEFT JOIN lectureUnits.completedUsers completedUsers + ON lectureUnits.id = completedUsers.lectureUnit.id AND completedUsers.user.id = learningPath.user.id LEFT JOIN FETCH competencies.exercises exercises - LEFT JOIN FETCH exercises.studentParticipations studentParticipations + LEFT JOIN exercises.studentParticipations studentParticipations + ON exercises.id = studentParticipations.exercise.id AND studentParticipations.student.id = learningPath.user.id WHERE learningPath.id = :learningPathId - AND (progress IS NULL OR progress.user.id = learningPath.user.id) - AND (completedUsers IS NULL OR completedUsers.user.id = learningPath.user.id) - AND (studentParticipations IS NULL OR studentParticipations.student.id = learningPath.user.id) """) + @EntityGraph(type = LOAD, attributePaths = { "competencies.userProgress", "competencies.lectureUnits.completedUsers", "competencies.exercises.studentParticipations" }) Optional findWithEagerCompetenciesAndProgressAndLearningObjectsAndCompletedUsersById(@Param("learningPathId") long learningPathId); default LearningPath findWithEagerCompetenciesAndProgressAndLearningObjectsAndCompletedUsersByIdElseThrow(long learningPathId) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathService.java b/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathService.java index 5bf554eec522..6349651e6f84 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathService.java @@ -19,7 +19,7 @@ import de.tum.in.www1.artemis.web.rest.dto.PageableSearchDTO; import de.tum.in.www1.artemis.web.rest.dto.SearchResultPageDTO; import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathHealthDTO; -import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathPageableSearchDTO; +import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathInformationDTO; import de.tum.in.www1.artemis.web.rest.dto.competency.NgxLearningPathDTO; import de.tum.in.www1.artemis.web.rest.util.PageUtil; @@ -106,11 +106,11 @@ public LearningPath generateLearningPathForUser(@NotNull Course course, @NotNull * @param course the course the learning paths are linked to * @return A wrapper object containing a list of all found learning paths and the total number of pages */ - public SearchResultPageDTO getAllOfCourseOnPageWithSize(@NotNull PageableSearchDTO search, @NotNull Course course) { + public SearchResultPageDTO getAllOfCourseOnPageWithSize(@NotNull PageableSearchDTO search, @NotNull Course course) { final var pageable = PageUtil.createLearningPathPageRequest(search); final var searchTerm = search.getSearchTerm(); final Page learningPathPage = learningPathRepository.findByLoginOrNameInCourse(searchTerm, course.getId(), pageable); - final List contentDTOs = learningPathPage.getContent().stream().map(LearningPathPageableSearchDTO::new).toList(); + final List contentDTOs = learningPathPage.getContent().stream().map(LearningPathInformationDTO::new).toList(); return new SearchResultPageDTO<>(contentDTOs, learningPathPage.getTotalPages()); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java index 5677ae8cfa25..c479cc7eb67c 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java @@ -23,7 +23,7 @@ import de.tum.in.www1.artemis.web.rest.dto.PageableSearchDTO; import de.tum.in.www1.artemis.web.rest.dto.SearchResultPageDTO; import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathHealthDTO; -import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathPageableSearchDTO; +import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathInformationDTO; import de.tum.in.www1.artemis.web.rest.dto.competency.NgxLearningPathDTO; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; import de.tum.in.www1.artemis.web.rest.errors.ConflictException; @@ -107,7 +107,7 @@ public ResponseEntity generateMissingLearningPathsForCourse(@PathVariable @GetMapping("courses/{courseId}/learning-paths") @FeatureToggle(Feature.LearningPaths) @EnforceAtLeastInstructor - public ResponseEntity> getLearningPathsOnPage(@PathVariable long courseId, PageableSearchDTO search) { + public ResponseEntity> getLearningPathsOnPage(@PathVariable long courseId, PageableSearchDTO search) { log.debug("REST request to get learning paths for course with id: {}", courseId); Course course = courseRepository.findByIdElseThrow(courseId); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); @@ -138,6 +138,25 @@ public ResponseEntity getHealthStatusForCourse(@PathVaria return ResponseEntity.ok(learningPathService.getHealthStatusForCourse(course)); } + /** + * GET learning-path/:learningPathId : Gets the learning path information. + * + * @param learningPathId the id of the learning path that should be fetched + * @return the ResponseEntity with status 200 (OK) and with body the learning path + */ + @GetMapping("learning-path/{learningPathId}") + @FeatureToggle(Feature.LearningPaths) + @EnforceAtLeastStudent + public ResponseEntity getLearningPath(@PathVariable long learningPathId) { + log.debug("REST request to get learning path with id: {}", learningPathId); + final var learningPath = learningPathRepository.findWithEagerUserByIdElseThrow(learningPathId); + final var user = userRepository.getUser(); + if (!user.getId().equals(learningPath.getUser().getId())) { + throw new AccessForbiddenException("You are not the owner of the learning path."); + } + return ResponseEntity.ok(new LearningPathInformationDTO(learningPath)); + } + /** * GET /learning-path/:learningPathId/graph : Gets the ngx representation of the learning path as a graph. * diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/LearningPathPageableSearchDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/LearningPathInformationDTO.java similarity index 65% rename from src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/LearningPathPageableSearchDTO.java rename to src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/LearningPathInformationDTO.java index 9f49626f34af..a76da4f78cbb 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/LearningPathPageableSearchDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/LearningPathInformationDTO.java @@ -3,9 +3,9 @@ import de.tum.in.www1.artemis.domain.competency.LearningPath; import de.tum.in.www1.artemis.web.rest.dto.user.UserNameAndLoginDTO; -public record LearningPathPageableSearchDTO(long id, UserNameAndLoginDTO user, int progress) { +public record LearningPathInformationDTO(long id, UserNameAndLoginDTO user, int progress) { - public LearningPathPageableSearchDTO(LearningPath learningPath) { + public LearningPathInformationDTO(LearningPath learningPath) { this(learningPath.getId(), new UserNameAndLoginDTO(learningPath.getUser()), learningPath.getProgress()); } } diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.html b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.html index 3dfbe0a378a5..99ca87abbbef 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.html +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.html @@ -10,19 +10,21 @@

- + +
-
- +
+ +
diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.ts index d9771c27a43e..1a5d629e0bac 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core'; -import { faCheckCircle, faCircle, faPlayCircle, faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCheckCircle, faCircle, faFlag, faFlagCheckered, faPlayCircle, faQuestionCircle, faSignsPost } from '@fortawesome/free-solid-svg-icons'; import { NgxLearningPathNode, NodeType } from 'app/entities/competency/learning-path.model'; import { Competency, CompetencyProgress } from 'app/entities/competency.model'; import { Exercise } from 'app/entities/exercise.model'; @@ -26,6 +26,9 @@ export class LearningPathGraphNodeComponent { faPlayCircle = faPlayCircle; faQuestionCircle = faQuestionCircle; faCircle = faCircle; + faSignsPost = faSignsPost; + faFlagCheckered = faFlagCheckered; + faFlag = faFlag; nodeDetailsData = new NodeDetailsData(); diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.html b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.html index bf88a3e419d5..7379d2c47da6 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.html +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.html @@ -22,7 +22,13 @@ - + @@ -37,4 +43,8 @@ + +
+ +
diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.scss b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.scss index 36d2fb2ebd59..9ac68e0a2c58 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.scss +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.scss @@ -1,13 +1,9 @@ .graph-container { - display: block; + display: flex; width: 100%; height: 100%; overflow: hidden; - .ngx-graph { - width: auto; - } - .node { display: flex; width: 100%; @@ -23,6 +19,15 @@ stroke: var(--body-color) !important; marker-end: url(#arrow); } + + .floating-icon-button { + position: absolute; + right: 0; + padding: 0.25em; + } + .floating-icon-button:hover { + cursor: pointer; + } } jhi-learning-path-graph-node:hover { @@ -35,6 +40,7 @@ jhi-learning-path-graph-node:hover { fa-icon { width: 100%; + display: flex; svg { margin: 10%; @@ -44,10 +50,24 @@ jhi-learning-path-graph-node:hover { } } +.node-xs { + width: 40%; + margin: 30%; +} + +.node-s { + width: 60%; + margin: 20%; +} + .completed { color: var(--bs-success); } +.current { + color: var(--bs-warning); +} + .node-details { display: block; max-width: 40vw; diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.ts index 74909ca63105..f51f44f50358 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.ts @@ -5,6 +5,10 @@ import * as shape from 'd3-shape'; import { Subject } from 'rxjs'; import { LearningPathService } from 'app/course/learning-paths/learning-path.service'; import { NgxLearningPathDTO, NgxLearningPathNode } from 'app/entities/competency/learning-path.model'; +import { ExerciseEntry, LectureUnitEntry } from 'app/course/learning-paths/participate/learning-path-storage.service'; +import { faEye } from '@fortawesome/free-solid-svg-icons'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { LearningPathProgressModalComponent } from 'app/course/learning-paths/progress-modal/learning-path-progress-modal.component'; export enum LearningPathViewMode { GRAPH = 'GRAPH', @@ -27,22 +31,28 @@ export class LearningPathGraphComponent implements OnInit { ngxLearningPath: NgxLearningPathDTO; ngxGraph?: NgxLearningPathDTO; ngxPath?: NgxLearningPathDTO; + highlightedNode?: NgxLearningPathNode; layout: string | Layout = 'dagreCluster'; curve = shape.curveBundle; - draggingEnabled = false; - panningEnabled = true; - zoomEnabled = true; - panOnZoom = true; + private _draggingEnabled = false; + private _panningEnabled = false; + private _zoomEnabled = false; + private _panOnZoom = false; update$: Subject = new Subject(); center$: Subject = new Subject(); zoomToFit$: Subject = new Subject(); + faEye = faEye; + + protected readonly PATH = LearningPathViewMode.PATH; + constructor( private activatedRoute: ActivatedRoute, private learningPathService: LearningPathService, + private modalService: NgbModal, ) {} ngOnInit() { @@ -51,6 +61,39 @@ export class LearningPathGraphComponent implements OnInit { } } + @Input() set draggingEnabled(value) { + this._draggingEnabled = value || false; + } + + get draggingEnabled() { + return this._draggingEnabled; + } + + @Input() set panningEnabled(value) { + this._panningEnabled = value || false; + } + + get panningEnabled() { + return this._panningEnabled; + } + + @Input() set zoomEnabled(value) { + this._zoomEnabled = value || false; + } + + get zoomEnabled() { + return this._zoomEnabled; + } + + @Input('panOnZoom') + get panOnZoom() { + return this._panOnZoom; + } + + set panOnZoom(value) { + this._panOnZoom = value || false; + } + refreshData() { if (this.ngxGraph) { this.loadGraphRepresentation(this.viewMode === LearningPathViewMode.GRAPH); @@ -122,4 +165,42 @@ export class LearningPathGraphComponent implements OnInit { this.loadDataIfNecessary(); this.update$.next(true); } + + highlightNode(learningObject: LectureUnitEntry | ExerciseEntry) { + if (this.viewMode === LearningPathViewMode.GRAPH) { + this.highlightedNode = this.findNode(learningObject, this.ngxGraph!); + } else { + this.highlightedNode = this.findNode(learningObject, this.ngxPath!); + } + this.update$.next(true); + } + + clearHighlighting() { + this.highlightedNode = undefined; + this.update$.next(true); + } + + private findNode(learningObject: LectureUnitEntry | ExerciseEntry, ngx: NgxLearningPathDTO) { + if (learningObject instanceof LectureUnitEntry) { + return ngx.nodes.find((node) => { + return node.linkedResource === learningObject.lectureUnitId && node.linkedResourceParent === learningObject.lectureId; + }); + } else { + return ngx.nodes.find((node) => { + return node.linkedResource === learningObject.exerciseId && !node.linkedResourceParent; + }); + } + } + + viewProgress() { + this.learningPathService.getLearningPath(this.learningPathId).subscribe((learningPathResponse) => { + const modalRef = this.modalService.open(LearningPathProgressModalComponent, { + size: 'xl', + backdrop: 'static', + windowClass: 'learning-path-modal', + }); + modalRef.componentInstance.courseId = this.courseId; + modalRef.componentInstance.learningPath = learningPathResponse.body!; + }); + } } diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.module.ts b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.module.ts new file mode 100644 index 000000000000..4a765c6aa816 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { NgxGraphModule } from '@swimlane/ngx-graph'; +import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; +import { LearningPathGraphNodeComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph-node.component'; +import { CompetencyNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component'; +import { ExerciseNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component'; +import { LectureUnitNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component'; +import { ArtemisCompetenciesModule } from 'app/course/competencies/competency.module'; + +@NgModule({ + imports: [ArtemisSharedModule, NgxGraphModule, ArtemisCompetenciesModule], + declarations: [LearningPathGraphComponent, LearningPathGraphNodeComponent, CompetencyNodeDetailsComponent, ExerciseNodeDetailsComponent, LectureUnitNodeDetailsComponent], + exports: [LearningPathGraphComponent], +}) +export class ArtemisLearningPathGraphModule {} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts index e5c9d0c19160..108f7ac6b56f 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts @@ -9,11 +9,11 @@ import { AlertService } from 'app/core/util/alert.service'; import { PageableSearch, SearchResult, SortingOrder } from 'app/shared/table/pageable-table'; import { LearningPathPagingService } from 'app/course/learning-paths/learning-path-paging.service'; import { SortService } from 'app/shared/service/sort.service'; -import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; +import { LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; import { faSort, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { HealthStatus, LearningPathHealthDTO, getWarningAction, getWarningBody, getWarningHint, getWarningTitle } from 'app/entities/competency/learning-path-health.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { LearningPathProgressModalComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-modal.component'; +import { LearningPathProgressModalComponent } from 'app/course/learning-paths/progress-modal/learning-path-progress-modal.component'; export enum TableColumn { ID = 'ID', @@ -41,7 +41,7 @@ export class LearningPathManagementComponent implements OnInit { sortingOrder: SortingOrder.ASCENDING, sortedColumn: TableColumn.ID, }; - content: SearchResult; + content: SearchResult; total = 0; private search = new Subject(); @@ -90,7 +90,7 @@ export class LearningPathManagementComponent implements OnInit { * @param item The item itself * @returns The ID of the item */ - trackId(index: number, item: LearningPathPageableSearchDTO): number { + trackId(index: number, item: LearningPathInformationDTO): number { return item.id!; } @@ -208,7 +208,7 @@ export class LearningPathManagementComponent implements OnInit { } } - viewLearningPath(learningPath: LearningPathPageableSearchDTO) { + viewLearningPath(learningPath: LearningPathInformationDTO) { const modalRef = this.modalService.open(LearningPathProgressModalComponent, { size: 'xl', backdrop: 'static', diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.module.ts b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.module.ts new file mode 100644 index 000000000000..d67070ca7763 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { ArtemisLearningPathProgressModule } from 'app/course/learning-paths/progress-modal/learning-path-progress.module'; +import { LearningPathManagementComponent } from 'app/course/learning-paths/learning-path-management/learning-path-management.component'; +import { LearningPathHealthStatusWarningComponent } from 'app/course/learning-paths/learning-path-management/learning-path-health-status-warning.component'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; + +@NgModule({ + imports: [ArtemisSharedModule, ArtemisSharedComponentModule, ArtemisLearningPathProgressModule], + declarations: [LearningPathManagementComponent, LearningPathHealthStatusWarningComponent], +}) +export class ArtemisLearningPathManagementModule {} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-paging.service.ts b/src/main/webapp/app/course/learning-paths/learning-path-paging.service.ts index 8a86f9a56e5a..4ea2ded2568e 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-paging.service.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path-paging.service.ts @@ -4,9 +4,9 @@ import { PagingService } from 'app/exercises/shared/manage/paging.service'; import { PageableSearch, SearchResult } from 'app/shared/table/pageable-table'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; +import { LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; -type EntityResponseType = SearchResult; +type EntityResponseType = SearchResult; @Injectable({ providedIn: 'root' }) export class LearningPathPagingService extends PagingService { public resourceUrl = 'api'; diff --git a/src/main/webapp/app/course/learning-paths/learning-path.service.ts b/src/main/webapp/app/course/learning-paths/learning-path.service.ts index 1b5f1200ba3a..e146b89e97a3 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path.service.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path.service.ts @@ -2,14 +2,18 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { HttpClient, HttpResponse } from '@angular/common/http'; import { LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; -import { NgxLearningPathDTO } from 'app/entities/competency/learning-path.model'; -import { map } from 'rxjs/operators'; +import { LearningPathInformationDTO, NgxLearningPathDTO } from 'app/entities/competency/learning-path.model'; +import { map, tap } from 'rxjs/operators'; +import { LearningPathStorageService } from 'app/course/learning-paths/participate/learning-path-storage.service'; @Injectable({ providedIn: 'root' }) export class LearningPathService { private resourceURL = 'api'; - constructor(private httpClient: HttpClient) {} + constructor( + private httpClient: HttpClient, + private learningPathStorageService: LearningPathStorageService, + ) {} enableLearningPaths(courseId: number): Observable> { return this.httpClient.put(`${this.resourceURL}/courses/${courseId}/learning-paths/enable`, null, { observe: 'response' }); @@ -23,6 +27,10 @@ export class LearningPathService { return this.httpClient.get(`${this.resourceURL}/courses/${courseId}/learning-path-health`, { observe: 'response' }); } + getLearningPath(learningPathId: number): Observable> { + return this.httpClient.get(`${this.resourceURL}/learning-path/${learningPathId}`, { observe: 'response' }); + } + getLearningPathNgxGraph(learningPathId: number): Observable> { return this.httpClient.get(`${this.resourceURL}/learning-path/${learningPathId}/graph`, { observe: 'response' }).pipe( map((ngxLearningPathResponse) => { @@ -36,6 +44,9 @@ export class LearningPathService { map((ngxLearningPathResponse) => { return this.sanitizeNgxLearningPathResponse(ngxLearningPathResponse); }), + tap((ngxLearningPathResponse) => { + this.learningPathStorageService.storeRecommendations(learningPathId, ngxLearningPathResponse.body!); + }), ); } diff --git a/src/main/webapp/app/course/learning-paths/learning-paths.module.ts b/src/main/webapp/app/course/learning-paths/learning-paths.module.ts index 862086b32d3e..d693ff33262c 100644 --- a/src/main/webapp/app/course/learning-paths/learning-paths.module.ts +++ b/src/main/webapp/app/course/learning-paths/learning-paths.module.ts @@ -2,23 +2,15 @@ import { NgModule } from '@angular/core'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { LearningPathManagementComponent } from 'app/course/learning-paths/learning-path-management/learning-path-management.component'; import { NgxGraphModule } from '@swimlane/ngx-graph'; import { ArtemisLectureUnitsModule } from 'app/overview/course-lectures/lecture-units.module'; -import { ArtemisCompetenciesModule } from 'app/course/competencies/competency.module'; -import { LearningPathProgressModalComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-modal.component'; -import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; -import { LearningPathProgressNavComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-nav.component'; -import { LearningPathGraphNodeComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph-node.component'; -import { CompetencyNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component'; -import { LectureUnitNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component'; -import { ExerciseNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component'; -import { LearningPathHealthStatusWarningComponent } from 'app/course/learning-paths/learning-path-management/learning-path-health-status-warning.component'; import { LearningPathContainerComponent } from 'app/course/learning-paths/participate/learning-path-container.component'; import { Authority } from 'app/shared/constants/authority.constants'; import { UserRouteAccessService } from 'app/core/auth/user-route-access-service'; import { RouterModule, Routes } from '@angular/router'; import { LearningPathGraphSidebarComponent } from 'app/course/learning-paths/participate/learning-path-graph-sidebar.component'; +import { ArtemisLearningPathProgressModule } from 'app/course/learning-paths/progress-modal/learning-path-progress.module'; +import { ArtemisLearningPathGraphModule } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.module'; const routes: Routes = [ { @@ -67,22 +59,11 @@ const routes: Routes = [ ArtemisSharedComponentModule, NgxGraphModule, ArtemisLectureUnitsModule, - ArtemisCompetenciesModule, RouterModule.forChild(routes), + ArtemisLearningPathGraphModule, + ArtemisLearningPathProgressModule, ], - declarations: [ - LearningPathManagementComponent, - LearningPathHealthStatusWarningComponent, - LearningPathProgressModalComponent, - LearningPathProgressNavComponent, - LearningPathGraphComponent, - LearningPathGraphNodeComponent, - CompetencyNodeDetailsComponent, - LectureUnitNodeDetailsComponent, - ExerciseNodeDetailsComponent, - LearningPathContainerComponent, - LearningPathGraphSidebarComponent, - ], + declarations: [LearningPathContainerComponent, LearningPathGraphSidebarComponent], exports: [LearningPathContainerComponent], }) export class ArtemisLearningPathsModule {} diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.html b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.html index a0b7ee135370..8de83f9d81cd 100644 --- a/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.html +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.html @@ -1,6 +1,7 @@
- - +
+
+ +
+
+ +
+
diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.ts b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.ts index 0f85e88d14ae..336d7d0795a4 100644 --- a/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.ts +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-container.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons'; import { Exercise } from 'app/entities/exercise.model'; @@ -13,7 +13,8 @@ import { AlertService } from 'app/core/util/alert.service'; import { LearningPathLectureUnitViewComponent } from 'app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component'; import { CourseExerciseDetailsComponent } from 'app/overview/exercise-details/course-exercise-details.component'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; -import { ExerciseEntry, LearningPathHistoryStorageService, LectureUnitEntry } from 'app/course/learning-paths/participate/learning-path-history-storage.service'; +import { ExerciseEntry, LearningPathStorageService, LectureUnitEntry, StorageEntry } from 'app/course/learning-paths/participate/learning-path-storage.service'; +import { LearningPathGraphSidebarComponent } from 'app/course/learning-paths/participate/learning-path-graph-sidebar.component'; @Component({ selector: 'jhi-learning-path-container', @@ -21,10 +22,12 @@ import { ExerciseEntry, LearningPathHistoryStorageService, LectureUnitEntry } fr templateUrl: './learning-path-container.component.html', }) export class LearningPathContainerComponent implements OnInit { + @ViewChild('graphSidebar') graphSidebar: LearningPathGraphSidebarComponent; + @Input() courseId: number; learningPathId: number; - learningObjectId: number; + learningObjectId?: number; lectureId?: number; lecture?: Lecture; lectureUnit?: LectureUnit; @@ -41,7 +44,7 @@ export class LearningPathContainerComponent implements OnInit { private learningPathService: LearningPathService, private lectureService: LectureService, private exerciseService: ExerciseService, - public learningPathHistoryStorageService: LearningPathHistoryStorageService, + public learningPathStorageService: LearningPathStorageService, ) {} ngOnInit() { @@ -52,43 +55,57 @@ export class LearningPathContainerComponent implements OnInit { } this.learningPathService.getLearningPathId(this.courseId).subscribe((learningPathIdResponse) => { this.learningPathId = learningPathIdResponse.body!; - - // load latest lecture unit or exercise that was accessed - this.onPrevTask(); }); } onNextTask() { + const entry = this.currentStateToEntry(); + // reset state to avoid invalid states + this.undefineAll(); + const recommendation = this.learningPathStorageService.getNextRecommendation(this.learningPathId, entry); + this.loadEntry(recommendation); + } + + onPrevTask() { + const entry = this.currentStateToEntry(); + // reset state to avoid invalid states + this.undefineAll(); + if (this.learningPathStorageService.hasPrevRecommendation(this.learningPathId, entry)) { + this.loadEntry(this.learningPathStorageService.getPrevRecommendation(this.learningPathId, entry)); + } + } + + currentStateToEntry() { if (this.lectureUnit?.id) { - this.learningPathHistoryStorageService.storeLectureUnit(this.learningPathId, this.lectureId!, this.lectureUnit.id); + return new LectureUnitEntry(this.lectureId!, this.lectureUnit.id); } else if (this.exercise?.id) { - this.learningPathHistoryStorageService.storeExercise(this.learningPathId, this.exercise.id); + return new ExerciseEntry(this.exercise.id); } - // reset state to avoid invalid states - this.undefineAll(); - // todo: load recommendation, part of next pr } - undefineAll() { + private undefineAll() { + // reset ids + this.lectureId = undefined; + this.learningObjectId = undefined; + // reset models this.lecture = undefined; this.lectureUnit = undefined; this.exercise = undefined; } - onPrevTask() { - // reset state to avoid invalid states - this.undefineAll(); - if (this.learningPathHistoryStorageService.hasPrevious(this.learningPathId)) { - const entry = this.learningPathHistoryStorageService.getPrevious(this.learningPathId); - if (entry instanceof LectureUnitEntry) { - this.learningObjectId = entry.lectureUnitId; - this.lectureId = entry.lectureId; - this.loadLectureUnit(); - } else if (entry instanceof ExerciseEntry) { - this.learningObjectId = entry.exerciseId; - this.loadExercise(); - } + private loadEntry(entry: StorageEntry | undefined) { + if (entry instanceof LectureUnitEntry) { + this.learningObjectId = entry.lectureUnitId; + this.lectureId = entry.lectureId; + this.loadLectureUnit(); + } else if (entry instanceof ExerciseEntry) { + this.learningObjectId = entry.exerciseId; + this.loadExercise(); + } else { + this.graphSidebar.learningPathGraphComponent.clearHighlighting(); + return; } + this.graphSidebar.learningPathGraphComponent.highlightNode(entry); } loadLectureUnit() { @@ -105,7 +122,7 @@ export class LearningPathContainerComponent implements OnInit { } loadExercise() { - this.exerciseService.getExerciseDetails(this.learningObjectId).subscribe({ + this.exerciseService.getExerciseDetails(this.learningObjectId!).subscribe({ next: (exerciseResponse) => { this.exercise = exerciseResponse.body!; }, @@ -128,7 +145,7 @@ export class LearningPathContainerComponent implements OnInit { } setupLectureUnitView(instance: LearningPathLectureUnitViewComponent) { - if (this.lecture) { + if (this.lecture && this.lectureUnit) { instance.lecture = this.lecture; instance.lectureUnit = this.lectureUnit!; } @@ -138,7 +155,7 @@ export class LearningPathContainerComponent implements OnInit { if (this.exercise) { instance.learningPathMode = true; instance.courseId = this.courseId; - instance.exerciseId = this.learningObjectId; + instance.exerciseId = this.learningObjectId!; } } @@ -146,19 +163,16 @@ export class LearningPathContainerComponent implements OnInit { if (node.type !== NodeType.LECTURE_UNIT && node.type !== NodeType.EXERCISE) { return; } - if (this.lectureUnit?.id) { - this.learningPathHistoryStorageService.storeLectureUnit(this.learningPathId, this.lectureId!, this.lectureUnit.id); - } else if (this.exercise?.id) { - this.learningPathHistoryStorageService.storeExercise(this.learningPathId, this.exercise.id); - } // reset state to avoid invalid states this.undefineAll(); this.learningObjectId = node.linkedResource!; this.lectureId = node.linkedResourceParent; if (node.type === NodeType.LECTURE_UNIT) { this.loadLectureUnit(); + this.graphSidebar.learningPathGraphComponent.highlightNode(new LectureUnitEntry(this.lectureId!, this.learningObjectId)); } else if (node.type === NodeType.EXERCISE) { this.loadExercise(); + this.graphSidebar.learningPathGraphComponent.highlightNode(new ExerciseEntry(this.learningObjectId)); } } } diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.html b/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.html index a98805f6f2ed..002d4d2dc111 100644 --- a/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.html +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-graph-sidebar.component.html @@ -19,6 +19,9 @@

[viewMode]="PATH" [courseId]="courseId" [learningPathId]="learningPathId" + [panningEnabled]="true" + [zoomEnabled]="true" + [panOnZoom]="true" (nodeClicked)="nodeClicked.emit($event)" > diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-history-storage.service.ts b/src/main/webapp/app/course/learning-paths/participate/learning-path-history-storage.service.ts deleted file mode 100644 index 47b75bdd4ccb..000000000000 --- a/src/main/webapp/app/course/learning-paths/participate/learning-path-history-storage.service.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Injectable } from '@angular/core'; - -/** - * This service is used to store the histories of learning path participation for the currently logged-in user. - */ -@Injectable({ providedIn: 'root' }) -export class LearningPathHistoryStorageService { - private readonly learningPathHistories: Map = new Map(); - - /** - * Stores the lecture unit in the learning path's history. - * - * @param learningPathId the id of the learning path to which the new entry should be added - * @param lectureId the id of the lecture, the lecture unit belongs to - * @param lectureUnitId the id of the lecture unit - */ - storeLectureUnit(learningPathId: number, lectureId: number, lectureUnitId: number) { - this.store(learningPathId, new LectureUnitEntry(lectureId, lectureUnitId)); - } - - /** - * Stores the exercise in the learning path's history. - * - * @param learningPathId the id of the learning path to which the new entry should be added - * @param exerciseId the id of the exercise - */ - storeExercise(learningPathId: number, exerciseId: number) { - this.store(learningPathId, new ExerciseEntry(exerciseId)); - } - - private store(learningPathId: number, entry: HistoryEntry) { - if (!entry) { - return; - } - if (!this.learningPathHistories.has(learningPathId)) { - this.learningPathHistories.set(learningPathId, []); - } - this.learningPathHistories.get(learningPathId)!.push(entry); - } - - /** - * Returns if the learning path's history stores at least one entry. - * - * @param learningPathId the id of the learning path for which the history should be checked - */ - hasPrevious(learningPathId: number): boolean { - if (this.learningPathHistories.has(learningPathId)) { - return this.learningPathHistories.get(learningPathId)!.length !== 0; - } - return false; - } - - /** - * Gets and removes the latest stored entry from the learning path's history. - * - * @param learningPathId - */ - getPrevious(learningPathId: number) { - if (!this.hasPrevious(learningPathId)) { - return undefined; - } - return this.learningPathHistories.get(learningPathId)!.pop(); - } -} - -export abstract class HistoryEntry {} - -export class LectureUnitEntry extends HistoryEntry { - lectureUnitId: number; - lectureId: number; - - constructor(lectureId: number, lectureUnitId: number) { - super(); - this.lectureId = lectureId; - this.lectureUnitId = lectureUnitId; - } -} - -export class ExerciseEntry extends HistoryEntry { - readonly exerciseId: number; - - constructor(exerciseId: number) { - super(); - this.exerciseId = exerciseId; - } -} diff --git a/src/main/webapp/app/course/learning-paths/participate/learning-path-storage.service.ts b/src/main/webapp/app/course/learning-paths/participate/learning-path-storage.service.ts new file mode 100644 index 000000000000..a7347a063c69 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/participate/learning-path-storage.service.ts @@ -0,0 +1,146 @@ +import { Injectable } from '@angular/core'; +import { NgxLearningPathDTO, NodeType } from 'app/entities/competency/learning-path.model'; + +/** + * This service is used to store the recommendations of learning path participation for the currently logged-in user. + */ +@Injectable({ providedIn: 'root' }) +export class LearningPathStorageService { + private readonly learningPathRecommendations: Map = new Map(); + + /** + * Simplifies and stores the recommended order of learning objects for the given learning path + * + * @param learningPathId the id of the learning path + * @param learningPath the learning path dto that should be stored + */ + storeRecommendations(learningPathId: number, learningPath: NgxLearningPathDTO) { + this.learningPathRecommendations.set(learningPathId, []); + let currentId = learningPath.nodes.map((node) => node.id).find((id) => !learningPath.edges.find((edge) => edge.target == id)); + while (currentId) { + const currentNode = learningPath.nodes.find((node) => node.id == currentId)!; + if (currentNode.type === NodeType.LECTURE_UNIT) { + this.learningPathRecommendations.get(learningPathId)!.push(new LectureUnitEntry(currentNode.linkedResourceParent!, currentNode.linkedResource!)); + } else if (currentNode.type === NodeType.EXERCISE) { + this.learningPathRecommendations.get(learningPathId)!.push(new ExerciseEntry(currentNode.linkedResource!)); + } + const edge = learningPath.edges.find((edge) => edge.source == currentId); + if (edge) { + currentId = edge.target; + } else { + currentId = undefined; + } + } + } + + /** + * Gets if the given learning object has a successor. + * + * @param learningPathId the id of the learning path + * @param entry the entry for which the successor should be checked + */ + hasNextRecommendation(learningPathId: number, entry?: StorageEntry): boolean { + if (!this.learningPathRecommendations.has(learningPathId)) { + return false; + } + if (!entry) { + return !!this.learningPathRecommendations.get(learningPathId)?.length; + } + const index = this.getIndexOf(learningPathId, entry); + return 0 <= index && index + 1 < this.learningPathRecommendations.get(learningPathId)!.length; + } + + /** + * Gets the next recommended entry for a learning object. + *

+ * First entry, if given entry undefined. + * Undefined if the current entry has no successor. + * @param learningPathId the id of the learning path + * @param entry the entry for which the successor should be returned + */ + getNextRecommendation(learningPathId: number, entry?: StorageEntry): StorageEntry | undefined { + if (!this.hasNextRecommendation(learningPathId, entry)) { + return undefined; + } + if (!entry) { + return this.learningPathRecommendations.get(learningPathId)![0]; + } + const nextIndex = this.getIndexOf(learningPathId, entry) + 1; + return this.learningPathRecommendations.get(learningPathId)![nextIndex]; + } + + /** + * Gets if the given learning object has a predecessor. + * + * @param learningPathId the id of the learning path + * @param entry the entry for which the predecessor should be checked + */ + hasPrevRecommendation(learningPathId: number, entry?: StorageEntry): boolean { + if (!this.learningPathRecommendations.has(learningPathId) || !entry) { + return false; + } + return 0 < this.getIndexOf(learningPathId, entry); + } + + /** + * Gets the prior recommended entry for a learning object. + *

+ * Undefined if the current entry has no predecessor. + * @param learningPathId the id of the learning path + * @param entry the entry for which the predecessor should be returned + */ + getPrevRecommendation(learningPathId: number, entry?: StorageEntry): StorageEntry | undefined { + if (!this.hasPrevRecommendation(learningPathId, entry)) { + return undefined; + } + const prevIndex = this.getIndexOf(learningPathId, entry!) - 1; + return this.learningPathRecommendations.get(learningPathId)![prevIndex]; + } + + private getIndexOf(learningPathId: number, entry: StorageEntry) { + if (!this.learningPathRecommendations.has(learningPathId)) { + return -1; + } + return this.learningPathRecommendations.get(learningPathId)!.findIndex((e: StorageEntry) => { + return entry.equals(e); + }); + } +} + +export abstract class StorageEntry { + abstract equals(other: StorageEntry): boolean; +} + +export class LectureUnitEntry extends StorageEntry { + readonly lectureUnitId: number; + readonly lectureId: number; + + constructor(lectureId: number, lectureUnitId: number) { + super(); + this.lectureId = lectureId; + this.lectureUnitId = lectureUnitId; + } + + equals(other: StorageEntry): boolean { + if (other instanceof LectureUnitEntry) { + return this.lectureId === other.lectureId && this.lectureUnitId === other.lectureUnitId; + } + return false; + } +} + +export class ExerciseEntry extends StorageEntry { + readonly exerciseId: number; + + constructor(exerciseId: number) { + super(); + this.exerciseId = exerciseId; + } + + equals(other: StorageEntry): boolean { + if (other instanceof ExerciseEntry) { + return this.exerciseId === other.exerciseId; + } + return false; + } +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.html b/src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-modal.component.html similarity index 88% rename from src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.html rename to src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-modal.component.html index fcdcfb6496b3..f52cd23f5332 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.html +++ b/src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-modal.component.html @@ -15,6 +15,9 @@ [viewMode]="GRAPH" [courseId]="courseId" [learningPathId]="learningPath.id!" + [panningEnabled]="true" + [zoomEnabled]="true" + [panOnZoom]="true" > diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.scss b/src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-modal.component.scss similarity index 100% rename from src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.scss rename to src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-modal.component.scss diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.ts b/src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-modal.component.ts similarity index 84% rename from src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.ts rename to src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-modal.component.ts index 546773b79928..6f9ae42bdae6 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.ts +++ b/src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-modal.component.ts @@ -1,7 +1,7 @@ import { Component, Input, ViewChild } from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { LearningPathGraphComponent, LearningPathViewMode } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; -import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; +import { LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; @Component({ selector: 'jhi-learning-path-progress-modal', styleUrls: ['./learning-path-progress-modal.component.scss'], @@ -9,7 +9,7 @@ import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning- }) export class LearningPathProgressModalComponent { @Input() courseId: number; - @Input() learningPath: LearningPathPageableSearchDTO; + @Input() learningPath: LearningPathInformationDTO; @ViewChild('learningPathGraphComponent') learningPathGraphComponent: LearningPathGraphComponent; constructor(private activeModal: NgbActiveModal) {} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.html b/src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-nav.component.html similarity index 100% rename from src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.html rename to src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-nav.component.html diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.ts b/src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-nav.component.ts similarity index 80% rename from src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.ts rename to src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-nav.component.ts index 9b4a8d38c686..592fd92cc190 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.ts +++ b/src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress-nav.component.ts @@ -1,13 +1,13 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { faArrowsRotate, faArrowsToEye, faXmark } from '@fortawesome/free-solid-svg-icons'; -import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; +import { LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; @Component({ selector: 'jhi-learning-path-progress-nav', templateUrl: './learning-path-progress-nav.component.html', }) export class LearningPathProgressNavComponent { - @Input() learningPath: LearningPathPageableSearchDTO; + @Input() learningPath: LearningPathInformationDTO; @Output() onRefresh: EventEmitter = new EventEmitter(); @Output() onCenterView: EventEmitter = new EventEmitter(); @Output() onClose: EventEmitter = new EventEmitter(); diff --git a/src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress.module.ts b/src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress.module.ts new file mode 100644 index 000000000000..e298d4a2fb5f --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/progress-modal/learning-path-progress.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { LearningPathProgressNavComponent } from 'app/course/learning-paths/progress-modal/learning-path-progress-nav.component'; +import { LearningPathProgressModalComponent } from 'app/course/learning-paths/progress-modal/learning-path-progress-modal.component'; +import { ArtemisLearningPathGraphModule } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.module'; + +@NgModule({ + imports: [ArtemisSharedModule, ArtemisLearningPathGraphModule], + declarations: [LearningPathProgressNavComponent, LearningPathProgressModalComponent], + exports: [LearningPathProgressModalComponent], +}) +export class ArtemisLearningPathProgressModule {} diff --git a/src/main/webapp/app/course/manage/course-management.module.ts b/src/main/webapp/app/course/manage/course-management.module.ts index 2ed6da192577..08a645469e4c 100644 --- a/src/main/webapp/app/course/manage/course-management.module.ts +++ b/src/main/webapp/app/course/manage/course-management.module.ts @@ -63,6 +63,7 @@ import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; import { ExerciseCategoriesModule } from 'app/shared/exercise-categories/exercise-categories.module'; import { CourseManagementTabBarComponent } from 'app/course/manage/course-management-tab-bar/course-management-tab-bar.component'; import { ArtemisExerciseCreateButtonsModule } from 'app/exercises/shared/manage/exercise-create-buttons.module'; +import { ArtemisLearningPathManagementModule } from 'app/course/learning-paths/learning-path-management/learning-path-management.module'; @NgModule({ imports: [ @@ -113,6 +114,7 @@ import { ArtemisExerciseCreateButtonsModule } from 'app/exercises/shared/manage/ ExerciseCategoriesModule, NgbNavModule, ArtemisExerciseCreateButtonsModule, + ArtemisLearningPathManagementModule, ], declarations: [ CourseManagementComponent, diff --git a/src/main/webapp/app/entities/competency/learning-path.model.ts b/src/main/webapp/app/entities/competency/learning-path.model.ts index 8834651fbd21..c8394047af95 100644 --- a/src/main/webapp/app/entities/competency/learning-path.model.ts +++ b/src/main/webapp/app/entities/competency/learning-path.model.ts @@ -14,7 +14,7 @@ export class LearningPath implements BaseEntity { constructor() {} } -export class LearningPathPageableSearchDTO { +export class LearningPathInformationDTO { public id?: number; public user?: UserNameAndLoginDTO; public progress?: number; diff --git a/src/main/webapp/i18n/de/competency.json b/src/main/webapp/i18n/de/competency.json index 2b52dae6d2d2..a5a334438222 100644 --- a/src/main/webapp/i18n/de/competency.json +++ b/src/main/webapp/i18n/de/competency.json @@ -198,6 +198,7 @@ "header": "Lernpfad" }, "participate": { + "viewProgress": "Aktuellen Fortschritt im Detail ansehen", "noTaskSelected": "Aktuell hast du keine Vorlesungseinheit oder Aufgabe ausgewählt.", "prev": "Zurück", "prevHint": "Zurück zur letzten Vorlesungseinheit oder Aufgabe", diff --git a/src/main/webapp/i18n/en/competency.json b/src/main/webapp/i18n/en/competency.json index 3ec6dc3de02a..fc6073fcbb69 100644 --- a/src/main/webapp/i18n/en/competency.json +++ b/src/main/webapp/i18n/en/competency.json @@ -197,6 +197,7 @@ "header": "Learning Path" }, "participate": { + "viewProgress": "View current progress details", "noTaskSelected": "Currently you have no lecture unit or exercise selected.", "prev": "Back", "prevHint": "Return to your previous lecture unit or exercise", diff --git a/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java index e20feec29ff6..ad1a1360c19d 100644 --- a/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java @@ -287,7 +287,7 @@ void testGenerateLearningPathOnEnrollment() throws Exception { @WithMockUser(username = INSTRUCTOR_OF_COURSE, roles = "INSTRUCTOR") void testGetLearningPathsOnPageForCourseLearningPathsDisabled() throws Exception { final var search = pageableSearchUtilService.configureSearch(""); - request.getSearchResult("/api/courses/" + course.getId() + "/learning-paths", HttpStatus.BAD_REQUEST, LearningPathPageableSearchDTO.class, + request.getSearchResult("/api/courses/" + course.getId() + "/learning-paths", HttpStatus.BAD_REQUEST, LearningPathInformationDTO.class, pageableSearchUtilService.searchMapping(search)); } @@ -296,7 +296,7 @@ void testGetLearningPathsOnPageForCourseLearningPathsDisabled() throws Exception void testGetLearningPathsOnPageForCourseEmpty() throws Exception { course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); final var search = pageableSearchUtilService.configureSearch(STUDENT_OF_COURSE + "SuffixThatAllowsTheResultToBeEmpty"); - final var result = request.getSearchResult("/api/courses/" + course.getId() + "/learning-paths", HttpStatus.OK, LearningPathPageableSearchDTO.class, + final var result = request.getSearchResult("/api/courses/" + course.getId() + "/learning-paths", HttpStatus.OK, LearningPathInformationDTO.class, pageableSearchUtilService.searchMapping(search)); assertThat(result.getResultsOnPage()).isNullOrEmpty(); } @@ -306,7 +306,7 @@ void testGetLearningPathsOnPageForCourseEmpty() throws Exception { void testGetLearningPathsOnPageForCourseExactlyStudent() throws Exception { course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); final var search = pageableSearchUtilService.configureSearch(STUDENT_OF_COURSE); - final var result = request.getSearchResult("/api/courses/" + course.getId() + "/learning-paths", HttpStatus.OK, LearningPathPageableSearchDTO.class, + final var result = request.getSearchResult("/api/courses/" + course.getId() + "/learning-paths", HttpStatus.OK, LearningPathInformationDTO.class, pageableSearchUtilService.searchMapping(search)); assertThat(result.getResultsOnPage()).hasSize(1); } @@ -395,6 +395,24 @@ void testGetHealthStatusForCourse() throws Exception { request.get("/api/courses/" + course.getId() + "/learning-path-health", HttpStatus.OK, LearningPathHealthDTO.class); } + @Test + @WithMockUser(username = STUDENT_OF_COURSE, roles = "USER") + void testGetLearningPathWithOwner() throws Exception { + course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); + final var student = userRepository.findOneByLogin(STUDENT_OF_COURSE).orElseThrow(); + final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), student.getId()); + request.get("/api/learning-path/" + learningPath.getId(), HttpStatus.OK, NgxLearningPathDTO.class); + } + + @Test + @WithMockUser(username = STUDENT_OF_COURSE, roles = "USER") + void testGetLearningPathOfOtherUser() throws Exception { + course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); + final var otherStudent = userRepository.findOneByLogin(TEST_PREFIX + "student2").orElseThrow(); + final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), otherStudent.getId()); + request.get("/api/learning-path/" + learningPath.getId(), HttpStatus.FORBIDDEN, NgxLearningPathDTO.class); + } + @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") @EnumSource(LearningPathResource.NgxRequestType.class) @WithMockUser(username = STUDENT_OF_COURSE, roles = "USER") diff --git a/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph.component.spec.ts b/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph.component.spec.ts index 558e007e3985..de582dbc093f 100644 --- a/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph.component.spec.ts @@ -2,11 +2,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ArtemisTestModule } from '../../../test.module'; import { LearningPathGraphComponent, LearningPathViewMode } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; import { LearningPathService } from 'app/course/learning-paths/learning-path.service'; -import { NgxLearningPathDTO, NgxLearningPathNode } from 'app/entities/competency/learning-path.model'; +import { NgxLearningPathDTO, NgxLearningPathNode, NodeType } from 'app/entities/competency/learning-path.model'; import { HttpResponse } from '@angular/common/http'; import { of } from 'rxjs'; -import { MockModule } from 'ng-mocks'; +import { MockDirective, MockModule, MockPipe } from 'ng-mocks'; import { NgxGraphModule } from '@swimlane/ngx-graph'; +import { ExerciseEntry, LectureUnitEntry } from 'app/course/learning-paths/participate/learning-path-storage.service'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; describe('LearningPathGraphComponent', () => { let fixture: ComponentFixture; @@ -14,12 +17,24 @@ describe('LearningPathGraphComponent', () => { let learningPathService: LearningPathService; let getLearningPathNgxGraphStub: jest.SpyInstance; let getLearningPathNgxPathStub: jest.SpyInstance; - const ngxGraph = { nodes: [{ id: '1' } as NgxLearningPathNode], edges: [] } as NgxLearningPathDTO; - const ngxPath = { nodes: [{ id: '2' } as NgxLearningPathNode], edges: [] } as NgxLearningPathDTO; + const ngxGraph = { + nodes: [ + { id: '1', linkedResource: 1, type: NodeType.EXERCISE } as NgxLearningPathNode, + { id: '2', linkedResource: 2, linkedResourceParent: 3, type: NodeType.LECTURE_UNIT } as NgxLearningPathNode, + ], + edges: [], + } as NgxLearningPathDTO; + const ngxPath = { + nodes: [ + { id: '3', linkedResource: 1, type: NodeType.EXERCISE } as NgxLearningPathNode, + { id: '4', linkedResource: 2, linkedResourceParent: 3, type: NodeType.LECTURE_UNIT } as NgxLearningPathNode, + ], + edges: [], + } as NgxLearningPathDTO; beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MockModule(NgxGraphModule)], + imports: [ArtemisTestModule, MockModule(NgxGraphModule), MockPipe(ArtemisTranslatePipe), MockDirective(NgbTooltip)], declarations: [LearningPathGraphComponent], }) .compileComponents() @@ -91,4 +106,32 @@ describe('LearningPathGraphComponent', () => { // make sure stub was not called again expect(getLearningPathNgxGraphStub).toHaveBeenCalledOnce(); }); + + it.each([ + [LearningPathViewMode.GRAPH, new ExerciseEntry(1), '1'], + [LearningPathViewMode.GRAPH, new LectureUnitEntry(3, 2), '2'], + [LearningPathViewMode.PATH, new ExerciseEntry(1), '3'], + [LearningPathViewMode.PATH, new LectureUnitEntry(3, 2), '4'], + ])('should set highlighted node on call', (viewMode, entry, expectedNodeId) => { + const updateStub = jest.spyOn(comp.update$, 'next'); + comp.viewMode = viewMode; + comp.learningPathId = 1; + fixture.detectChanges(); + expect(comp.highlightedNode).toBeUndefined(); + comp.highlightNode(entry); + expect(comp.highlightedNode).toBeTruthy(); + expect(comp.highlightedNode!.id).toBe(expectedNodeId); + expect(updateStub).toHaveBeenCalledTimes(2); + }); + + it('should clear highlighting', () => { + const updateStub = jest.spyOn(comp.update$, 'next'); + comp.viewMode = LearningPathViewMode.GRAPH; + comp.learningPathId = 1; + fixture.detectChanges(); + comp.highlightedNode = { id: '1337' } as NgxLearningPathNode; + comp.clearHighlighting(); + expect(comp.highlightedNode).toBeUndefined(); + expect(updateStub).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-modal.component.spec.ts b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-modal.component.spec.ts index f0fc95d2572b..444d0a7690a8 100644 --- a/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-modal.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-modal.component.spec.ts @@ -2,11 +2,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ArtemisTestModule } from '../../../test.module'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { MockComponent } from 'ng-mocks'; -import { LearningPathProgressModalComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-modal.component'; +import { LearningPathProgressModalComponent } from 'app/course/learning-paths/progress-modal/learning-path-progress-modal.component'; import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; import { By } from '@angular/platform-browser'; -import { LearningPathProgressNavComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-nav.component'; -import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; +import { LearningPathProgressNavComponent } from 'app/course/learning-paths/progress-modal/learning-path-progress-nav.component'; +import { LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; describe('LearningPathProgressModalComponent', () => { let fixture: ComponentFixture; @@ -35,7 +35,7 @@ describe('LearningPathProgressModalComponent', () => { it('should display learning path graph if learning path is present', () => { comp.courseId = 2; - comp.learningPath = { id: 1 } as LearningPathPageableSearchDTO; + comp.learningPath = { id: 1 } as LearningPathInformationDTO; fixture.detectChanges(); expect(fixture.debugElement.query(By.css('.graph')).nativeElement).toBeTruthy(); }); diff --git a/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-nav.component.spec.ts b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-nav.component.spec.ts index f579a9163537..877e9718b84e 100644 --- a/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-nav.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-nav.component.spec.ts @@ -1,8 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ArtemisTestModule } from '../../../test.module'; import { By } from '@angular/platform-browser'; -import { LearningPathProgressNavComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-nav.component'; -import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; +import { LearningPathProgressNavComponent } from 'app/course/learning-paths/progress-modal/learning-path-progress-nav.component'; +import { LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; import { UserNameAndLoginDTO } from 'app/core/user/user.model'; import { MockPipe } from 'ng-mocks'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; @@ -14,7 +14,7 @@ describe('LearningPathProgressNavComponent', () => { let onRefreshStub: jest.SpyInstance; let onCenterViewStub: jest.SpyInstance; let onCloseStub: jest.SpyInstance; - let learningPath: LearningPathPageableSearchDTO; + let learningPath: LearningPathInformationDTO; beforeEach(() => { TestBed.configureTestingModule({ @@ -28,7 +28,7 @@ describe('LearningPathProgressNavComponent', () => { onRefreshStub = jest.spyOn(comp.onRefresh, 'emit'); onCenterViewStub = jest.spyOn(comp.onCenterView, 'emit'); onCloseStub = jest.spyOn(comp.onClose, 'emit'); - learningPath = new LearningPathPageableSearchDTO(); + learningPath = new LearningPathInformationDTO(); learningPath.user = new UserNameAndLoginDTO(); learningPath.user.name = 'some arbitrary name'; learningPath.user.login = 'somearbitrarylogin'; diff --git a/src/test/javascript/spec/component/learning-paths/participate/learning-path-container.component.spec.ts b/src/test/javascript/spec/component/learning-paths/participate/learning-path-container.component.spec.ts index 400c91dc2364..778f1a8f6c40 100644 --- a/src/test/javascript/spec/component/learning-paths/participate/learning-path-container.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/participate/learning-path-container.component.spec.ts @@ -1,12 +1,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MockComponent, MockModule } from 'ng-mocks'; +import { MockDirective, MockModule, MockPipe } from 'ng-mocks'; import { ArtemisTestModule } from '../../../test.module'; import { of } from 'rxjs'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { HttpResponse } from '@angular/common/http'; import { LearningPathContainerComponent } from 'app/course/learning-paths/participate/learning-path-container.component'; import { LearningPathService } from 'app/course/learning-paths/learning-path.service'; -import { NgxLearningPathNode, NodeType } from 'app/entities/competency/learning-path.model'; +import { NgxLearningPathDTO, NgxLearningPathNode, NodeType } from 'app/entities/competency/learning-path.model'; import { LectureService } from 'app/lecture/lecture.service'; import { Lecture } from 'app/entities/lecture.model'; import { LectureUnit } from 'app/entities/lecture-unit/lectureUnit.model'; @@ -17,7 +17,10 @@ import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; import { TextExercise } from 'app/entities/text-exercise.model'; import { LearningPathLectureUnitViewComponent } from 'app/course/learning-paths/participate/lecture-unit/learning-path-lecture-unit-view.component'; import { CourseExerciseDetailsComponent } from 'app/overview/exercise-details/course-exercise-details.component'; -import { ExerciseEntry, LearningPathHistoryStorageService, LectureUnitEntry } from 'app/course/learning-paths/participate/learning-path-history-storage.service'; +import { ExerciseEntry, LearningPathStorageService, LectureUnitEntry } from 'app/course/learning-paths/participate/learning-path-storage.service'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; describe('LearningPathContainerComponent', () => { let fixture: ComponentFixture; @@ -32,15 +35,21 @@ describe('LearningPathContainerComponent', () => { let exerciseService: ExerciseService; let exercise: Exercise; let getExerciseDetailsStub: jest.SpyInstance; - let historyService: LearningPathHistoryStorageService; - let storeLectureUnitStub: jest.SpyInstance; - let storeExerciseStub: jest.SpyInstance; - let hasPreviousStub: jest.SpyInstance; - let getPreviousStub: jest.SpyInstance; + let historyService: LearningPathStorageService; + let getNextRecommendationStub: jest.SpyInstance; + let hasPrevRecommendationStub: jest.SpyInstance; + let getPrevRecommendationStub: jest.SpyInstance; beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MockComponent(LearningPathGraphSidebarComponent), MockModule(RouterModule)], + imports: [ + ArtemisTestModule, + MockModule(RouterModule), + MockPipe(ArtemisTranslatePipe), + MockDirective(NgbTooltip), + LearningPathGraphSidebarComponent, + LearningPathGraphComponent, + ], declarations: [LearningPathContainerComponent], providers: [ { @@ -77,11 +86,10 @@ describe('LearningPathContainerComponent', () => { exerciseService = TestBed.inject(ExerciseService); getExerciseDetailsStub = jest.spyOn(exerciseService, 'getExerciseDetails').mockReturnValue(of(new HttpResponse({ body: exercise }))); - historyService = TestBed.inject(LearningPathHistoryStorageService); - storeLectureUnitStub = jest.spyOn(historyService, 'storeLectureUnit'); - storeExerciseStub = jest.spyOn(historyService, 'storeExercise'); - hasPreviousStub = jest.spyOn(historyService, 'hasPrevious'); - getPreviousStub = jest.spyOn(historyService, 'getPrevious'); + historyService = TestBed.inject(LearningPathStorageService); + getNextRecommendationStub = jest.spyOn(historyService, 'getNextRecommendation'); + hasPrevRecommendationStub = jest.spyOn(historyService, 'hasPrevRecommendation'); + getPrevRecommendationStub = jest.spyOn(historyService, 'getPrevRecommendation'); fixture.detectChanges(); }); @@ -97,54 +105,41 @@ describe('LearningPathContainerComponent', () => { expect(getLearningPathIdStub).toHaveBeenCalledWith(1); }); - it('should store current lecture unit in history', () => { + it('should retrieve next recommended entry', () => { comp.learningObjectId = lectureUnit.id!; comp.lectureUnit = lectureUnit; comp.lectureId = lecture.id; comp.lecture = lecture; fixture.detectChanges(); comp.onNextTask(); - expect(storeLectureUnitStub).toHaveBeenCalledOnce(); - expect(storeLectureUnitStub).toHaveBeenCalledWith(learningPathId, lecture.id, lectureUnit.id); - expect(storeExerciseStub).not.toHaveBeenCalled(); - }); - - it('should store current exercise in history', () => { - comp.learningObjectId = exercise.id!; - comp.exercise = exercise; - fixture.detectChanges(); - comp.onNextTask(); - expect(storeLectureUnitStub).not.toHaveBeenCalled(); - expect(storeExerciseStub).toHaveBeenCalledOnce(); - expect(storeExerciseStub).toHaveBeenCalledWith(learningPathId, exercise.id); + expect(getNextRecommendationStub).toHaveBeenCalledExactlyOnceWith(comp.learningPathId, new LectureUnitEntry(lecture.id!, lectureUnit.id!)); }); - it('should load no previous task if history is empty', () => { - expect(historyService.hasPrevious(learningPathId)).toBeFalsy(); + it('should not load previous task if no task selected', () => { comp.onPrevTask(); - expect(getPreviousStub).not.toHaveBeenCalled(); + expect(getPrevRecommendationStub).not.toHaveBeenCalled(); expect(findWithDetailsStub).not.toHaveBeenCalled(); expect(getExerciseDetailsStub).not.toHaveBeenCalled(); }); it('should load previous lecture unit', () => { - hasPreviousStub.mockReturnValue(true); - getPreviousStub.mockReturnValue(new LectureUnitEntry(lecture.id!, lectureUnit.id!)); + hasPrevRecommendationStub.mockReturnValue(true); + getPrevRecommendationStub.mockReturnValue(new LectureUnitEntry(lecture.id!, lectureUnit.id!)); + comp.graphSidebar.learningPathGraphComponent.ngxPath = { nodes: [], edges: [] } as NgxLearningPathDTO; fixture.detectChanges(); comp.onPrevTask(); - expect(findWithDetailsStub).toHaveBeenCalled(); - expect(findWithDetailsStub).toHaveBeenCalledWith(lecture.id); + expect(findWithDetailsStub).toHaveBeenCalledExactlyOnceWith(lecture.id); expect(getExerciseDetailsStub).not.toHaveBeenCalled(); }); it('should load previous exercise', () => { - hasPreviousStub.mockReturnValue(true); - getPreviousStub.mockReturnValue(new ExerciseEntry(exercise.id!)); + hasPrevRecommendationStub.mockReturnValue(true); + getPrevRecommendationStub.mockReturnValue(new ExerciseEntry(exercise.id!)); fixture.detectChanges(); + comp.graphSidebar.learningPathGraphComponent.ngxPath = { nodes: [], edges: [] } as NgxLearningPathDTO; comp.onPrevTask(); expect(findWithDetailsStub).not.toHaveBeenCalled(); - expect(getExerciseDetailsStub).toHaveBeenCalled(); - expect(getExerciseDetailsStub).toHaveBeenCalledWith(exercise.id); + expect(getExerciseDetailsStub).toHaveBeenCalledExactlyOnceWith(exercise.id); }); it('should set properties of lecture unit view on activate', () => { @@ -171,6 +166,7 @@ describe('LearningPathContainerComponent', () => { }); it('should handle lecture unit node click', () => { + comp.graphSidebar.learningPathGraphComponent.ngxPath = { nodes: [], edges: [] } as NgxLearningPathDTO; const node = { id: 'some-id', type: NodeType.LECTURE_UNIT, linkedResource: 2, linkedResourceParent: 3 } as NgxLearningPathNode; comp.onNodeClicked(node); expect(comp.learningObjectId).toBe(node.linkedResource); @@ -179,33 +175,10 @@ describe('LearningPathContainerComponent', () => { }); it('should handle exercise node click', () => { + comp.graphSidebar.learningPathGraphComponent.ngxPath = { nodes: [], edges: [] } as NgxLearningPathDTO; const node = { id: 'some-id', type: NodeType.EXERCISE, linkedResource: 2 } as NgxLearningPathNode; comp.onNodeClicked(node); expect(comp.learningObjectId).toBe(node.linkedResource); expect(getExerciseDetailsStub).toHaveBeenCalledWith(node.linkedResource); }); - - it('should handle store current lecture unit in history on node click', () => { - comp.learningObjectId = lectureUnit.id!; - comp.lectureUnit = lectureUnit; - comp.lectureId = lecture.id; - comp.lecture = lecture; - fixture.detectChanges(); - const node = { id: 'some-id', type: NodeType.EXERCISE, linkedResource: 2 } as NgxLearningPathNode; - comp.onNodeClicked(node); - expect(storeLectureUnitStub).toHaveBeenCalledOnce(); - expect(storeLectureUnitStub).toHaveBeenCalledWith(learningPathId, lecture.id, lectureUnit.id!); - expect(storeExerciseStub).not.toHaveBeenCalled(); - }); - - it('should handle store current exercise in history on node click', () => { - comp.learningObjectId = exercise.id!; - comp.exercise = exercise; - fixture.detectChanges(); - const node = { id: 'some-id', type: NodeType.EXERCISE, linkedResource: 2 } as NgxLearningPathNode; - comp.onNodeClicked(node); - expect(storeLectureUnitStub).not.toHaveBeenCalled(); - expect(storeExerciseStub).toHaveBeenCalledOnce(); - expect(storeExerciseStub).toHaveBeenCalledWith(learningPathId, exercise.id!); - }); }); diff --git a/src/test/javascript/spec/service/learning-path-history-storage.service.spec.ts b/src/test/javascript/spec/service/learning-path-history-storage.service.spec.ts deleted file mode 100644 index 292821cd115d..000000000000 --- a/src/test/javascript/spec/service/learning-path-history-storage.service.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { ArtemisTestModule } from '../test.module'; -import { ExerciseEntry, LearningPathHistoryStorageService, LectureUnitEntry } from 'app/course/learning-paths/participate/learning-path-history-storage.service'; - -describe('LearningPathHistoryStorageService', () => { - let historyStorageService: LearningPathHistoryStorageService; - let learningPathId: number; - let lectureId: number; - let lectureUnitId: number; - let exerciseId: number; - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule], - }) - .compileComponents() - .then(() => { - historyStorageService = new LearningPathHistoryStorageService(); - learningPathId = 1; - lectureId = 2; - lectureUnitId = 3; - exerciseId = 4; - }); - }); - - it('should return undefined if no previous is present', () => { - expect(historyStorageService.hasPrevious(learningPathId)).toBeFalsy(); - const entry = historyStorageService.getPrevious(learningPathId); - expect(entry).toBeUndefined(); - }); - - it('should handle single lecture unit', () => { - expect(historyStorageService.hasPrevious(learningPathId)).toBeFalsy(); - historyStorageService.storeLectureUnit(learningPathId, lectureId, lectureUnitId); - expect(historyStorageService.hasPrevious(learningPathId)).toBeTruthy(); - const entry = historyStorageService.getPrevious(learningPathId); - expect(entry).not.toBeNull(); - expect(entry).toBeInstanceOf(LectureUnitEntry); - const lectureUnitEntry = entry; - expect(lectureUnitEntry.lectureId).toBe(lectureId); - expect(lectureUnitEntry.lectureUnitId).toBe(lectureUnitId); - expect(historyStorageService.hasPrevious(learningPathId)).toBeFalsy(); - }); - - it('should handle single exercise', () => { - expect(historyStorageService.hasPrevious(learningPathId)).toBeFalsy(); - historyStorageService.storeExercise(learningPathId, exerciseId); - expect(historyStorageService.hasPrevious(learningPathId)).toBeTruthy(); - const entry = historyStorageService.getPrevious(learningPathId); - expect(entry).not.toBeNull(); - expect(entry).toBeInstanceOf(ExerciseEntry); - const exerciseEntry = entry; - expect(exerciseEntry.exerciseId).toBe(exerciseId); - expect(historyStorageService.hasPrevious(learningPathId)).toBeFalsy(); - }); - - it('should handle mixed sequence', () => { - expect(historyStorageService.hasPrevious(learningPathId)).toBeFalsy(); - historyStorageService.storeExercise(learningPathId, exerciseId); - expect(historyStorageService.hasPrevious(learningPathId)).toBeTruthy(); - historyStorageService.storeLectureUnit(learningPathId, lectureId, lectureUnitId); - expect(historyStorageService.hasPrevious(learningPathId)).toBeTruthy(); - historyStorageService.storeExercise(learningPathId, 11); - expect(historyStorageService.hasPrevious(learningPathId)).toBeTruthy(); - - // exercise 2 - let entry = historyStorageService.getPrevious(learningPathId); - expect(entry).not.toBeNull(); - expect(entry).toBeInstanceOf(ExerciseEntry); - const exercise2Entry = entry; - expect(exercise2Entry.exerciseId).toBe(11); - expect(historyStorageService.hasPrevious(learningPathId)).toBeTruthy(); - - // lecture unit - entry = historyStorageService.getPrevious(learningPathId); - expect(entry).not.toBeNull(); - expect(entry).toBeInstanceOf(LectureUnitEntry); - const lectureUnitEntry = entry; - expect(lectureUnitEntry.lectureId).toBe(lectureId); - expect(lectureUnitEntry.lectureUnitId).toBe(lectureUnitId); - expect(historyStorageService.hasPrevious(learningPathId)).toBeTruthy(); - - // exercise 1 - entry = historyStorageService.getPrevious(learningPathId); - expect(entry).not.toBeNull(); - expect(entry).toBeInstanceOf(ExerciseEntry); - const exerciseEntry = entry; - expect(exerciseEntry.exerciseId).toBe(exerciseId); - expect(historyStorageService.hasPrevious(learningPathId)).toBeFalsy(); - }); - - it('should handle multiple learning paths', () => { - const learningPath2Id = 11; - expect(historyStorageService.hasPrevious(learningPathId)).toBeFalsy(); - expect(historyStorageService.hasPrevious(learningPath2Id)).toBeFalsy(); - - // lecture unit in learningPath(1) - historyStorageService.storeLectureUnit(learningPathId, lectureId, lectureUnitId); - - expect(historyStorageService.hasPrevious(learningPathId)).toBeTruthy(); - expect(historyStorageService.hasPrevious(learningPath2Id)).toBeFalsy(); - - // exercise in learningPath(2) - historyStorageService.storeExercise(learningPath2Id, exerciseId); - - expect(historyStorageService.hasPrevious(learningPathId)).toBeTruthy(); - expect(historyStorageService.hasPrevious(learningPath2Id)).toBeTruthy(); - - let entry = historyStorageService.getPrevious(learningPathId); - expect(entry).not.toBeNull(); - expect(entry).toBeInstanceOf(LectureUnitEntry); - const lectureUnitEntry = entry; - expect(lectureUnitEntry.lectureId).toBe(lectureId); - expect(lectureUnitEntry.lectureUnitId).toBe(lectureUnitId); - - expect(historyStorageService.hasPrevious(learningPathId)).toBeFalsy(); - expect(historyStorageService.hasPrevious(learningPath2Id)).toBeTruthy(); - - entry = historyStorageService.getPrevious(learningPath2Id); - expect(entry).not.toBeNull(); - expect(entry).toBeInstanceOf(ExerciseEntry); - const exerciseEntry = entry; - expect(exerciseEntry.exerciseId).toBe(exerciseId); - - expect(historyStorageService.hasPrevious(learningPathId)).toBeFalsy(); - expect(historyStorageService.hasPrevious(learningPath2Id)).toBeFalsy(); - }); -}); diff --git a/src/test/javascript/spec/service/learning-path-storage.service.spec.ts b/src/test/javascript/spec/service/learning-path-storage.service.spec.ts new file mode 100644 index 000000000000..daa3f9c4ec03 --- /dev/null +++ b/src/test/javascript/spec/service/learning-path-storage.service.spec.ts @@ -0,0 +1,189 @@ +import { TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../test.module'; +import { ExerciseEntry, LearningPathStorageService, LectureUnitEntry } from 'app/course/learning-paths/participate/learning-path-storage.service'; +import { NgxLearningPathDTO, NgxLearningPathEdge, NgxLearningPathNode, NodeType } from 'app/entities/competency/learning-path.model'; + +describe('LearningPathStorageService', () => { + let storageService: LearningPathStorageService; + let learningPathId1: number; + let learningPathId2: number; + let ngxPathEmpty: NgxLearningPathDTO; + let ngxPath1: NgxLearningPathDTO; + let ngxPath2: NgxLearningPathDTO; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + }) + .compileComponents() + .then(() => { + storageService = new LearningPathStorageService(); + learningPathId1 = 1; + learningPathId2 = 2; + + ngxPath1 = { + nodes: [ + { id: '1', type: NodeType.COMPETENCY_START } as NgxLearningPathNode, + { id: '2', type: NodeType.LECTURE_UNIT, linkedResource: 5, linkedResourceParent: 6 } as NgxLearningPathNode, + { id: '3', type: NodeType.EXERCISE, linkedResource: 7 } as NgxLearningPathNode, + { id: '4', type: NodeType.COMPETENCY_END } as NgxLearningPathNode, + ], + edges: [ + { id: '8', source: '1', target: '2' } as NgxLearningPathEdge, + { id: '9', source: '2', target: '3' } as NgxLearningPathEdge, + { id: '10', source: '3', target: '4' } as NgxLearningPathEdge, + ], + } as NgxLearningPathDTO; + + ngxPath2 = { + nodes: [ + { id: '11', type: NodeType.COMPETENCY_START } as NgxLearningPathNode, + { id: '12', type: NodeType.LECTURE_UNIT, linkedResource: 15, linkedResourceParent: 16 } as NgxLearningPathNode, + { id: '13', type: NodeType.EXERCISE, linkedResource: 17 } as NgxLearningPathNode, + { id: '14', type: NodeType.COMPETENCY_END } as NgxLearningPathNode, + ], + edges: [ + { id: '18', source: '11', target: '12' } as NgxLearningPathEdge, + { id: '19', source: '12', target: '13' } as NgxLearningPathEdge, + { id: '20', source: '13', target: '14' } as NgxLearningPathEdge, + ], + } as NgxLearningPathDTO; + + ngxPathEmpty = { nodes: [], edges: [] } as NgxLearningPathDTO; + }); + }); + + it('should have no recommendation for empty learning path', () => { + storageService.storeRecommendations(learningPathId1, ngxPathEmpty); + expect(storageService.hasPrevRecommendation(learningPathId1)).toBeFalsy(); + expect(storageService.getPrevRecommendation(learningPathId1)).toBeUndefined(); + + const arbitraryEntry = new ExerciseEntry(1337); + expect(storageService.hasPrevRecommendation(learningPathId1, arbitraryEntry)).toBeFalsy(); + expect(storageService.getPrevRecommendation(learningPathId1, arbitraryEntry)).toBeUndefined(); + + expect(storageService.hasNextRecommendation(learningPathId1)).toBeFalsy(); + expect(storageService.getNextRecommendation(learningPathId1)).toBeUndefined(); + + expect(storageService.hasNextRecommendation(learningPathId1, arbitraryEntry)).toBeFalsy(); + expect(storageService.getNextRecommendation(learningPathId1, arbitraryEntry)).toBeUndefined(); + }); + + it('should retrieve first entry as next recommendation for undefined entry', () => { + storageService.storeRecommendations(learningPathId1, ngxPath1); + expect(storageService.hasNextRecommendation(learningPathId1)).toBeTruthy(); + const recommendation = storageService.getNextRecommendation(learningPathId1); + expect(recommendation).toBeInstanceOf(LectureUnitEntry); + const lectureUnitEntry = recommendation as LectureUnitEntry; + expect(lectureUnitEntry.lectureUnitId).toBe(5); + expect(lectureUnitEntry.lectureId).toBe(6); + }); + + it('should retrieve successor entry as next recommendation', () => { + storageService.storeRecommendations(learningPathId1, ngxPath1); + const lectureUnitEntry = new LectureUnitEntry(6, 5); + expect(storageService.hasNextRecommendation(learningPathId1, lectureUnitEntry)).toBeTruthy(); + const recommendation = storageService.getNextRecommendation(learningPathId1, lectureUnitEntry); + expect(recommendation).toBeInstanceOf(ExerciseEntry); + const exerciseEntry = recommendation as ExerciseEntry; + expect(exerciseEntry.exerciseId).toBe(7); + }); + + it('should retrieve no entry as next recommendation for last entry', () => { + storageService.storeRecommendations(learningPathId1, ngxPath1); + const exerciseEntry = new ExerciseEntry(7); + expect(storageService.hasNextRecommendation(learningPathId1, exerciseEntry)).toBeFalsy(); + expect(storageService.getNextRecommendation(learningPathId1, exerciseEntry)).toBeUndefined(); + }); + + it('should not retrieve entry as previous recommendation for undefined entry', () => { + storageService.storeRecommendations(learningPathId1, ngxPath1); + expect(storageService.hasPrevRecommendation(learningPathId1)).toBeFalsy(); + expect(storageService.getPrevRecommendation(learningPathId1)).toBeUndefined(); + }); + + it('should not retrieve entry as previous recommendation for first entry', () => { + storageService.storeRecommendations(learningPathId1, ngxPath1); + const lectureUnitEntry = new LectureUnitEntry(6, 5); + expect(storageService.hasPrevRecommendation(learningPathId1, lectureUnitEntry)).toBeFalsy(); + expect(storageService.getPrevRecommendation(learningPathId1, lectureUnitEntry)).toBeUndefined(); + }); + + it('should retrieve predecessor entry as next recommendation', () => { + storageService.storeRecommendations(learningPathId1, ngxPath1); + const exerciseEntry = new ExerciseEntry(7); + expect(storageService.hasPrevRecommendation(learningPathId1, exerciseEntry)).toBeTruthy(); + const recommendation = storageService.getPrevRecommendation(learningPathId1, exerciseEntry); + expect(recommendation).toBeInstanceOf(LectureUnitEntry); + const lectureUnitEntry = recommendation as LectureUnitEntry; + expect(lectureUnitEntry.lectureUnitId).toBe(5); + expect(lectureUnitEntry.lectureId).toBe(6); + }); + + it('should handle multiple learning paths - next, entry undefined', () => { + storageService.storeRecommendations(learningPathId1, ngxPath1); + storageService.storeRecommendations(learningPathId2, ngxPath2); + + expect(storageService.hasNextRecommendation(learningPathId1)).toBeTruthy(); + const recommendation1 = storageService.getNextRecommendation(learningPathId1); + expect(recommendation1).toBeInstanceOf(LectureUnitEntry); + const recommendedLectureUnitEntry1 = recommendation1 as LectureUnitEntry; + expect(recommendedLectureUnitEntry1.lectureUnitId).toBe(5); + expect(recommendedLectureUnitEntry1.lectureId).toBe(6); + expect(storageService.hasNextRecommendation(learningPathId2)).toBeTruthy(); + const recommendation2 = storageService.getNextRecommendation(learningPathId2); + expect(recommendation2).toBeInstanceOf(LectureUnitEntry); + const recommendedLectureUnitEntry2 = recommendation2 as LectureUnitEntry; + expect(recommendedLectureUnitEntry2.lectureUnitId).toBe(15); + expect(recommendedLectureUnitEntry2.lectureId).toBe(16); + }); + + it('should handle multiple learning paths - next, entry defined', () => { + storageService.storeRecommendations(learningPathId1, ngxPath1); + storageService.storeRecommendations(learningPathId2, ngxPath2); + + const lectureUnitEntry1 = new LectureUnitEntry(6, 5); + const lectureUnitEntry2 = new LectureUnitEntry(16, 15); + expect(storageService.hasNextRecommendation(learningPathId1, lectureUnitEntry1)).toBeTruthy(); + const recommendation1 = storageService.getNextRecommendation(learningPathId1, lectureUnitEntry1); + expect(recommendation1).toBeInstanceOf(ExerciseEntry); + const exerciseEntry1 = recommendation1 as ExerciseEntry; + expect(exerciseEntry1.exerciseId).toBe(7); + expect(storageService.hasNextRecommendation(learningPathId1, lectureUnitEntry2)).toBeFalsy(); + expect(storageService.hasNextRecommendation(learningPathId2, lectureUnitEntry1)).toBeFalsy(); + expect(storageService.hasNextRecommendation(learningPathId2, lectureUnitEntry2)).toBeTruthy(); + const recommendation2 = storageService.getNextRecommendation(learningPathId2, lectureUnitEntry2); + expect(recommendation2).toBeInstanceOf(ExerciseEntry); + const exerciseEntry2 = recommendation2 as ExerciseEntry; + expect(exerciseEntry2.exerciseId).toBe(17); + }); + + it('should handle multiple learning paths - prev, entry undefined', () => { + storageService.storeRecommendations(learningPathId1, ngxPath1); + storageService.storeRecommendations(learningPathId2, ngxPath2); + + expect(storageService.hasPrevRecommendation(learningPathId1)).toBeFalsy(); + expect(storageService.hasPrevRecommendation(learningPathId2)).toBeFalsy(); + }); + + it('should handle multiple learning paths - prev, entry defined', () => { + storageService.storeRecommendations(learningPathId1, ngxPath1); + storageService.storeRecommendations(learningPathId2, ngxPath2); + + const exerciseEntry1 = new ExerciseEntry(7); + const exerciseEntry2 = new ExerciseEntry(17); + expect(storageService.hasPrevRecommendation(learningPathId1, exerciseEntry1)).toBeTruthy(); + const recommendation1 = storageService.getPrevRecommendation(learningPathId1, exerciseEntry1); + expect(recommendation1).toBeInstanceOf(LectureUnitEntry); + const lectureUnitEntry1 = recommendation1 as LectureUnitEntry; + expect(lectureUnitEntry1.lectureUnitId).toBe(5); + expect(lectureUnitEntry1.lectureId).toBe(6); + expect(storageService.hasPrevRecommendation(learningPathId1, exerciseEntry2)).toBeFalsy(); + expect(storageService.hasPrevRecommendation(learningPathId2, exerciseEntry1)).toBeFalsy(); + expect(storageService.hasPrevRecommendation(learningPathId2, exerciseEntry2)).toBeTruthy(); + const recommendation2 = storageService.getPrevRecommendation(learningPathId2, exerciseEntry2); + expect(recommendation2).toBeInstanceOf(LectureUnitEntry); + const lectureUnitEntry2 = recommendation2 as LectureUnitEntry; + expect(lectureUnitEntry2.lectureUnitId).toBe(15); + expect(lectureUnitEntry2.lectureId).toBe(16); + }); +}); diff --git a/src/test/javascript/spec/service/learning-path.service.spec.ts b/src/test/javascript/spec/service/learning-path.service.spec.ts index aaa3b43badfc..1a7053e0fb36 100644 --- a/src/test/javascript/spec/service/learning-path.service.spec.ts +++ b/src/test/javascript/spec/service/learning-path.service.spec.ts @@ -3,9 +3,11 @@ import { LearningPathService } from 'app/course/learning-paths/learning-path.ser import { ArtemisTestModule } from '../test.module'; import { HttpClient } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; +import { LearningPathStorageService } from 'app/course/learning-paths/participate/learning-path-storage.service'; describe('LearningPathService', () => { let learningPathService: LearningPathService; + let storageService: LearningPathStorageService; let httpService: HttpClient; let putStub: jest.SpyInstance; let getStub: jest.SpyInstance; @@ -18,7 +20,8 @@ describe('LearningPathService', () => { .compileComponents() .then(() => { httpService = TestBed.inject(HttpClient); - learningPathService = new LearningPathService(httpService); + storageService = TestBed.inject(LearningPathStorageService); + learningPathService = new LearningPathService(httpService, storageService); putStub = jest.spyOn(httpService, 'put'); getStub = jest.spyOn(httpService, 'get'); }); @@ -46,6 +49,12 @@ describe('LearningPathService', () => { expect(getStub).toHaveBeenCalledWith('api/courses/1/learning-path-health', { observe: 'response' }); }); + it('should send a request to the server to get learning path information', () => { + learningPathService.getLearningPath(1).subscribe(); + expect(getStub).toHaveBeenCalledOnce(); + expect(getStub).toHaveBeenCalledWith('api/learning-path/1', { observe: 'response' }); + }); + it('should send a request to the server to get ngx graph representation of learning path', () => { learningPathService.getLearningPathNgxGraph(1).subscribe(); expect(getStub).toHaveBeenCalledOnce();