From 96284c76e0f60e0bf5a8ca212818f284e129cb87 Mon Sep 17 00:00:00 2001 From: Johannes Wiest Date: Fri, 18 Oct 2024 08:57:01 +0200 Subject: [PATCH] Learning paths: Redesign learning path instructor view (#9144) --- .../atlas/dto/CompetencyGraphNodeDTO.java | 2 +- .../atlas/dto/LearningPathHealthDTO.java | 2 +- .../CompetencyProgressRepository.java | 12 ++ .../learningpath/LearningPathService.java | 35 +++-- .../atlas/web/LearningPathResource.java | 15 ++ .../core/repository/CourseRepository.java | 8 + .../webapp/app/admin/metrics/metrics.model.ts | 1 + .../competency-graph-modal.component.ts | 11 +- .../competency-node.component.html | 6 +- .../competency-node.component.ts | 2 + .../learning-path-nav-overview.component.ts | 7 +- .../learning-paths-analytics.component.html | 27 ++++ .../learning-paths-analytics.component.scss | 7 + .../learning-paths-analytics.component.ts | 44 ++++++ ...earning-paths-configuration.component.html | 41 +++++ .../learning-paths-configuration.component.ts | 78 ++++++++++ .../learning-paths-state.component.html | 40 +++++ .../learning-paths-state.component.scss | 18 +++ .../learning-paths-state.component.ts | 85 ++++++++++ .../learning-paths-table.component.html | 66 ++++++++ .../learning-paths-table.component.scss | 18 +++ .../learning-paths-table.component.ts | 94 +++++++++++ .../learning-path-management.component.html | 134 ++++++++-------- .../learning-path-management.component.ts | 6 +- ...arning-path-instructor-page.component.html | 39 +++++ ...arning-path-instructor-page.component.scss | 13 ++ ...learning-path-instructor-page.component.ts | 62 ++++++++ .../services/base-api-http.service.ts | 47 ++++++ .../services/learning-path-api.service.ts | 33 ++++ .../course/manage/course-management.route.ts | 4 +- .../competency/learning-path-health.model.ts | 24 +-- .../competency/learning-path.model.ts | 11 +- .../i18n/de/learningPathManagement.json | 60 +++++++ .../i18n/en/learningPathManagement.json | 60 +++++++ .../LearningPathIntegrationTest.java | 11 +- .../service/LearningPathServiceTest.java | 18 --- ...learning-paths-analytics.component.spec.ts | 95 +++++++++++ ...ning-paths-configuration.component.spec.ts | 147 ++++++++++++++++++ .../learning-paths-state.component.spec.ts | 145 +++++++++++++++++ .../learning-paths-table.component.spec.ts | 147 ++++++++++++++++++ ...learning-path-management.component.spec.ts | 19 +-- ...ing-path-instructor-page.component.spec.ts | 145 +++++++++++++++++ ...arning-path-student-page.component.spec.ts | 9 +- .../learning-path-api.service.spec.ts | 53 +++++++ 44 files changed, 1734 insertions(+), 167 deletions(-) create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.html create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.scss create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.html create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.html create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.scss create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.scss create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.html create mode 100644 src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.scss create mode 100644 src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.ts create mode 100644 src/main/webapp/i18n/de/learningPathManagement.json create mode 100644 src/main/webapp/i18n/en/learningPathManagement.json create mode 100644 src/test/javascript/spec/component/learning-paths/components/learning-paths-analytics.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/components/learning-paths-configuration.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/components/learning-paths-state.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/components/learning-paths-table.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/pages/learning-path-instructor-page.component.spec.ts diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyGraphNodeDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyGraphNodeDTO.java index 59feee0edd6b..c56876064668 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyGraphNodeDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyGraphNodeDTO.java @@ -12,7 +12,7 @@ public record CompetencyGraphNodeDTO(String id, String label, ZonedDateTime softDueDate, Double value, CompetencyNodeValueType valueType) { public enum CompetencyNodeValueType { - MASTERY_PROGRESS + MASTERY_PROGRESS, AVERAGE_MASTERY_PROGRESS, } public static CompetencyGraphNodeDTO of(@NotNull CourseCompetency competency, Double value, CompetencyNodeValueType valueType) { diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java index 8592378c6a50..05d621746267 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java @@ -14,6 +14,6 @@ public LearningPathHealthDTO(Set status) { } public enum HealthStatus { - OK, DISABLED, MISSING, NO_COMPETENCIES, NO_RELATIONS + MISSING, NO_COMPETENCIES, NO_RELATIONS } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java index 85c627b06408..2e397c06db36 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java @@ -94,4 +94,16 @@ SELECT COUNT(cp) AND c = :competency """) Set findAllPriorByCompetencyId(@Param("competency") CourseCompetency competency, @Param("user") User userId); + + @Query(""" + SELECT COALESCE(GREATEST(0.0, LEAST(1.0, AVG(cp.progress * cp.confidence / com.masteryThreshold))), 0.0) + FROM CompetencyProgress cp + LEFT JOIN cp.competency com + LEFT JOIN com.course c + LEFT JOIN cp.user u + WHERE com.id = :competencyId + AND cp.progress > 0 + AND c.studentGroupName MEMBER OF u.groups + """) + double findAverageOfAllNonZeroStudentProgressByCompetencyId(@Param("competencyId") long competencyId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java index 190565c5c35c..ea2a4bd9ec37 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java @@ -33,6 +33,7 @@ import de.tum.cit.aet.artemis.atlas.repository.CompetencyProgressRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; +import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; import de.tum.cit.aet.artemis.atlas.repository.LearningPathRepository; import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -89,10 +90,13 @@ public class LearningPathService { private final StudentParticipationRepository studentParticipationRepository; + private final CourseCompetencyRepository courseCompetencyRepository; + public LearningPathService(UserRepository userRepository, LearningPathRepository learningPathRepository, CompetencyProgressRepository competencyProgressRepository, LearningPathNavigationService learningPathNavigationService, CourseRepository courseRepository, CompetencyRepository competencyRepository, CompetencyRelationRepository competencyRelationRepository, LearningPathNgxService learningPathNgxService, - LectureUnitCompletionRepository lectureUnitCompletionRepository, StudentParticipationRepository studentParticipationRepository) { + LectureUnitCompletionRepository lectureUnitCompletionRepository, StudentParticipationRepository studentParticipationRepository, + CourseCompetencyRepository courseCompetencyRepository) { this.userRepository = userRepository; this.learningPathRepository = learningPathRepository; this.competencyProgressRepository = competencyProgressRepository; @@ -103,6 +107,7 @@ public LearningPathService(UserRepository userRepository, LearningPathRepository this.learningPathNgxService = learningPathNgxService; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; this.studentParticipationRepository = studentParticipationRepository; + this.courseCompetencyRepository = courseCompetencyRepository; } /** @@ -298,20 +303,11 @@ else if (learningPath.isStartedByStudent()) { * @return dto containing the health status and additional information (missing learning paths) if needed */ public LearningPathHealthDTO getHealthStatusForCourse(@NotNull Course course) { - if (!course.getLearningPathsEnabled()) { - return new LearningPathHealthDTO(Set.of(LearningPathHealthDTO.HealthStatus.DISABLED)); - } - Set status = new HashSet<>(); Long numberOfMissingLearningPaths = checkMissingLearningPaths(course, status); checkNoCompetencies(course, status); checkNoRelations(course, status); - // if no issues where found, add OK status - if (status.isEmpty()) { - status.add(LearningPathHealthDTO.HealthStatus.OK); - } - return new LearningPathHealthDTO(status, numberOfMissingLearningPaths); } @@ -366,6 +362,25 @@ public LearningPathCompetencyGraphDTO generateLearningPathCompetencyGraph(@NotNu return new LearningPathCompetencyGraphDTO(progressDTOs, relationDTOs); } + /** + * Generates the graph of competencies with the student's progress for the given learning path. + * + * @param courseId the id of the course for which the graph should be generated + * @return dto containing the competencies and relations of the learning path + */ + public LearningPathCompetencyGraphDTO generateLearningPathCompetencyInstructorGraph(long courseId) { + List competencies = courseCompetencyRepository.findByCourseIdOrderById(courseId); + Set progressDTOs = competencies.stream().map(competency -> { + double averageMasteryProgress = competencyProgressRepository.findAverageOfAllNonZeroStudentProgressByCompetencyId(competency.getId()); + return CompetencyGraphNodeDTO.of(competency, averageMasteryProgress, CompetencyGraphNodeDTO.CompetencyNodeValueType.AVERAGE_MASTERY_PROGRESS); + }).collect(Collectors.toSet()); + + Set relations = competencyRelationRepository.findAllWithHeadAndTailByCourseId(courseId); + Set relationDTOs = relations.stream().map(CompetencyGraphEdgeDTO::of).collect(Collectors.toSet()); + + return new LearningPathCompetencyGraphDTO(progressDTOs, relationDTOs); + } + /** * Generates Ngx graph representation of the learning path graph. * diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java index f69dae28f80c..43a8135f27cb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java @@ -203,6 +203,21 @@ public ResponseEntity getLearningPathCompetencyG return ResponseEntity.ok(learningPathService.generateLearningPathCompetencyGraph(learningPath, user)); } + /** + * GET courses/{courseId}/learning-path/competency-instructor-graph : Gets the competency instructor graph + * + * @param courseId the id of the course for which the graph should be fetched + * @return the ResponseEntity with status 200 (OK) and with body the graph + */ + @GetMapping("courses/{courseId}/learning-path/competency-instructor-graph") + @FeatureToggle(Feature.LearningPaths) + @EnforceAtLeastInstructorInCourse + public ResponseEntity getLearningPathCompetencyInstructorGraph(@PathVariable long courseId) { + log.debug("REST request to get competency instructor graph for learning path with id: {}", courseId); + + return ResponseEntity.ok(learningPathService.generateLearningPathCompetencyInstructorGraph(courseId)); + } + /** * GET learning-path/:learningPathId/graph : Gets the ngx representation of the learning path as a graph. * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java index fa3bba8a4b73..ad4c3ab139f5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java @@ -322,6 +322,14 @@ GROUP BY SUBSTRING(CAST(s.submissionDate AS string), 1, 10), p.student.login """) List findAllNotEndedCoursesByManagementGroupNames(@Param("now") ZonedDateTime now, @Param("userGroups") List userGroups); + @Query(""" + SELECT COUNT(DISTINCT ug.userId) + FROM Course c + JOIN UserGroup ug ON c.studentGroupName = ug.group + WHERE c.id = :courseId + """) + int countCourseStudents(@Param("courseId") long courseId); + /** * Counts the number of members of a course, i.e. users that are a member of the course's student, tutor, editor or instructor group. * Users that are part of multiple groups are NOT counted multiple times. diff --git a/src/main/webapp/app/admin/metrics/metrics.model.ts b/src/main/webapp/app/admin/metrics/metrics.model.ts index dbed33af6fc7..cc476415b8ad 100644 --- a/src/main/webapp/app/admin/metrics/metrics.model.ts +++ b/src/main/webapp/app/admin/metrics/metrics.model.ts @@ -83,6 +83,7 @@ export interface Services { export enum HttpMethod { Post = 'POST', Get = 'GET', + Put = 'PUT', Delete = 'DELETE', Patch = 'PATCH', } diff --git a/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts b/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts index 8dfd9971432e..ac7a27af9f09 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts @@ -1,7 +1,7 @@ import { Component, effect, inject, input, signal } from '@angular/core'; import { FontAwesomeModule, IconDefinition } from '@fortawesome/angular-fontawesome'; import { faXmark } from '@fortawesome/free-solid-svg-icons'; -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { CompetencyGraphComponent } from 'app/course/learning-paths/components/competency-graph/competency-graph.component'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; @@ -46,4 +46,13 @@ export class CompetencyGraphModalComponent { closeModal(): void { this.activeModal.close(); } + + static openCompetencyGraphModal(modalService: NgbModal, learningPathId: number): void { + const modalRef = modalService.open(CompetencyGraphModalComponent, { + size: 'xl', + backdrop: 'static', + windowClass: 'competency-graph-modal', + }); + modalRef.componentInstance.learningPathId = signal(learningPathId); + } } diff --git a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html index 77bb3e447289..3c546d22b01d 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html +++ b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html @@ -1,9 +1,11 @@
- - @if (valueType() === CompetencyGraphNodeValueType.MASTERY_PROGRESS) { + @if (valueType() === CompetencyGraphNodeValueType.MASTERY_PROGRESS || valueType() === CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS) { {{ value() }} % + } @else { + {{ value() }} } diff --git a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts index 2ff110c01954..5365bb22387b 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts @@ -46,6 +46,7 @@ export class CompetencyNodeComponent implements AfterViewInit { isYellow(): boolean { switch (this.valueType()) { case CompetencyGraphNodeValueType.MASTERY_PROGRESS: + case CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS: return this.value() > 0 && this.value() < 100; default: return false; @@ -55,6 +56,7 @@ export class CompetencyNodeComponent implements AfterViewInit { isGray(): boolean { switch (this.valueType()) { case CompetencyGraphNodeValueType.MASTERY_PROGRESS: + case CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS: return this.value() === 0; default: return false; diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts index a63830f98bc7..07722fb3d0e7 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts @@ -61,11 +61,6 @@ export class LearningPathNavOverviewComponent { } openCompetencyGraph(): void { - const modalRef = this.modalService.open(CompetencyGraphModalComponent, { - size: 'xl', - backdrop: 'static', - windowClass: 'competency-graph-modal', - }); - modalRef.componentInstance.learningPathId = this.learningPathId; + CompetencyGraphModalComponent.openCompetencyGraphModal(this.modalService, this.learningPathId()); } } diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.html new file mode 100644 index 000000000000..86d03a787e5d --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.html @@ -0,0 +1,27 @@ +
+
+
+
+
+ +
+
+ @if (isLoading()) { +
+
+ +
+
+ } @else if (instructorCompetencyGraph()) { + + } +
+
+
+ + +
+ + +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.scss b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.scss new file mode 100644 index 000000000000..64570aa984c9 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.scss @@ -0,0 +1,7 @@ +.learning-paths-analytics-container { + height: 500px; + + .learning-paths-analytics-graph-selection-container { + border-right: var(--bs-border-width) solid var(--border-color); + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts new file mode 100644 index 000000000000..41a8261eb206 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts @@ -0,0 +1,44 @@ +import { Component, effect, inject, input, signal } from '@angular/core'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { CompetencyGraphDTO, CompetencyGraphNodeValueType } from 'app/entities/competency/learning-path.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { CompetencyGraphComponent } from 'app/course/learning-paths/components/competency-graph/competency-graph.component'; +import { onError } from 'app/shared/util/global.utils'; + +@Component({ + selector: 'jhi-learning-paths-analytics', + standalone: true, + imports: [ArtemisSharedCommonModule, CompetencyGraphComponent], + templateUrl: './learning-paths-analytics.component.html', + styleUrl: './learning-paths-analytics.component.scss', +}) +export class LearningPathsAnalyticsComponent { + protected readonly CompetencyGraphNodeValueType = CompetencyGraphNodeValueType; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + + readonly courseId = input.required(); + + readonly isLoading = signal(false); + readonly instructorCompetencyGraph = signal(undefined); + + readonly valueSelection = signal(CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS); + + constructor() { + effect(() => this.loadInstructionCompetencyGraph(this.courseId()), { allowSignalWrites: true }); + } + + private async loadInstructionCompetencyGraph(courseId: number): Promise { + try { + this.isLoading.set(true); + const instructorCompetencyGraph = await this.learningPathApiService.getLearningPathInstructorCompetencyGraph(courseId); + this.instructorCompetencyGraph.set(instructorCompetencyGraph); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.html new file mode 100644 index 000000000000..3edcb13c956f --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.html @@ -0,0 +1,41 @@ +
+
+
+ @if (isEditMode()) { + + } @else { + + } +
+
+
+ @if (isConfigLoading()) { +
+
+ +
+
+ } @else { + + + + } +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts new file mode 100644 index 000000000000..23e5cb8e8612 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts @@ -0,0 +1,78 @@ +import { Component, computed, effect, inject, input, signal } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { LearningPathApiService } from '../../services/learning-path-api.service'; +import { LearningPathsConfigurationDTO } from 'app/entities/competency/learning-path.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; + +@Component({ + selector: 'jhi-learning-paths-configuration', + standalone: true, + imports: [FontAwesomeModule, ArtemisSharedCommonModule, ArtemisSharedComponentModule], + templateUrl: './learning-paths-configuration.component.html', + styleUrls: ['../../pages/learning-path-instructor-page/learning-path-instructor-page.component.scss'], +}) +export class LearningPathsConfigurationComponent { + protected readonly faSpinner = faSpinner; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + + readonly courseId = input.required(); + + readonly isEditMode = signal(false); + readonly configHasBeenChanged = signal(false); + + readonly isConfigLoading = signal(false); + readonly isSaving = signal(false); + private readonly learningPathsConfiguration = signal(undefined); + readonly includeAllGradedExercisesEnabled = computed(() => this.learningPathsConfiguration()?.includeAllGradedExercises ?? false); + + constructor() { + effect(() => this.loadLearningPathsConfiguration(this.courseId()), { allowSignalWrites: true }); + } + + private async loadLearningPathsConfiguration(courseId: number): Promise { + try { + this.isConfigLoading.set(true); + const learningPathsConfiguration = await this.learningPathApiService.getLearningPathsConfiguration(courseId); + this.learningPathsConfiguration.set(learningPathsConfiguration); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isConfigLoading.set(false); + } + } + + protected toggleIncludeAllGradedExercises(): void { + this.configHasBeenChanged.set(true); + this.learningPathsConfiguration.set({ + ...this.learningPathsConfiguration(), + includeAllGradedExercises: !this.includeAllGradedExercisesEnabled(), + }); + } + + protected async saveLearningPathsConfiguration(): Promise { + if (this.configHasBeenChanged()) { + try { + this.isSaving.set(true); + await this.learningPathApiService.updateLearningPathsConfiguration(this.courseId(), this.learningPathsConfiguration()!); + this.alertService.success('artemisApp.learningPathManagement.learningPathsConfiguration.saveSuccess'); + this.isEditMode.set(false); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isSaving.set(false); + } + } else { + this.isEditMode.set(false); + } + } + + protected enableEditMode(): void { + this.isEditMode.set(true); + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.html new file mode 100644 index 000000000000..cb2e0fb12c93 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.html @@ -0,0 +1,40 @@ +
+
+
+ +
+
+
+ @if (isLoading()) { +
+
+ +
+
+ } @else { + @for (healthState of learningPathHealthState(); let first = $first; track healthState) { +
+ +

+ +
+ } @empty { +
+ +
+ } + } +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.scss b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.scss new file mode 100644 index 000000000000..6354cc0912ab --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.scss @@ -0,0 +1,18 @@ +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; + +.learning-paths-state-container { + border-left: 2px solid $info; + + &.danger-state { + border-left-color: $danger; + } + + &.warning-state { + border-left-color: $warning; + } + + &.info-state { + border-left-color: $info; + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts new file mode 100644 index 000000000000..e5fc57092472 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts @@ -0,0 +1,85 @@ +import { Component, computed, effect, inject, input, signal } from '@angular/core'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { HealthStatus, LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { ActivatedRoute, Router } from '@angular/router'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'jhi-learning-paths-state', + standalone: true, + imports: [ArtemisSharedCommonModule], + templateUrl: './learning-paths-state.component.html', + styleUrls: ['./learning-paths-state.component.scss', '../../pages/learning-path-instructor-page/learning-path-instructor-page.component.scss'], +}) +export class LearningPathsStateComponent { + protected readonly faSpinner = faSpinner; + + private readonly baseTranslationKey = 'artemisApp.learningPathManagement.learningPathsState.type'; + readonly translationKeys: Record = { + [HealthStatus.MISSING]: `${this.baseTranslationKey}.missing`, + [HealthStatus.NO_COMPETENCIES]: `${this.baseTranslationKey}.noCompetencies`, + [HealthStatus.NO_RELATIONS]: `${this.baseTranslationKey}.noRelations`, + }; + + readonly stateCssClasses: Record = { + [HealthStatus.MISSING]: 'warning-state', + [HealthStatus.NO_COMPETENCIES]: 'danger-state', + [HealthStatus.NO_RELATIONS]: 'warning-state', + }; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + private readonly router = inject(Router); + private readonly activatedRoute = inject(ActivatedRoute); + + readonly courseId = input.required(); + + readonly isLoading = signal(false); + private readonly learningPathHealth = signal(undefined); + readonly learningPathHealthState = computed(() => this.learningPathHealth()?.status ?? []); + + constructor() { + effect(() => this.loadLearningPathHealthState(this.courseId()), { allowSignalWrites: true }); + } + + protected async loadLearningPathHealthState(courseId: number): Promise { + try { + this.isLoading.set(true); + const learningPathHealthState = await this.learningPathApiService.getLearningPathHealthStatus(courseId); + this.learningPathHealth.set(learningPathHealthState); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } + + protected async handleHealthStateAction(healthState: HealthStatus): Promise { + switch (healthState) { + case HealthStatus.MISSING: + await this.generateMissingLearningPaths(); + break; + case HealthStatus.NO_COMPETENCIES: + case HealthStatus.NO_RELATIONS: + await this.navigateToManageCompetencyPage(); + break; + } + } + + private async navigateToManageCompetencyPage(): Promise { + await this.router.navigate(['../competency-management'], { relativeTo: this.activatedRoute }); + } + + private async generateMissingLearningPaths(): Promise { + try { + await this.learningPathApiService.generateMissingLearningPaths(this.courseId()); + this.alertService.success(`${this.baseTranslationKey}.missing.successAlert`); + await this.loadLearningPathHealthState(this.courseId()); + } catch (error) { + onError(this.alertService, error); + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html new file mode 100644 index 000000000000..4f4b23a690e0 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html @@ -0,0 +1,66 @@ +
+
+
+
+ @if (isLoading()) { + + } + +
+
+
+
+
+ + + + + + + + + + + @for (learningPath of learningPaths(); track learningPath.id) { + + + + + + + } @empty { + + + + } + +
#
{{ learningPath.id }} + + + + + {{ learningPath.progress }} % +
+ +
+
+ +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.scss b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.scss new file mode 100644 index 000000000000..e0a8bf4315b9 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.scss @@ -0,0 +1,18 @@ +.learning-paths-table-container { + height: 185px; + overflow-y: auto; + + table { + thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--bs-card-bg); + } + } +} + +.pagination { + height: 27px; + overflow-y: hidden; +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.ts new file mode 100644 index 000000000000..3d5a50483faa --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.ts @@ -0,0 +1,94 @@ +import { Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; +import { SearchResult, SearchTermPageableSearch, SortingOrder } from 'app/shared/table/pageable-table'; +import { onError } from 'app/shared/util/global.utils'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { CompetencyGraphModalComponent } from 'app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component'; +import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; + +enum TableColumn { + ID = 'ID', + USER_NAME = 'USER_NAME', + USER_LOGIN = 'USER_LOGIN', + PROGRESS = 'PROGRESS', +} + +@Component({ + selector: 'jhi-learning-paths-table', + standalone: true, + imports: [ArtemisSharedCommonModule], + templateUrl: './learning-paths-table.component.html', + styleUrls: ['./learning-paths-table.component.scss', '../../pages/learning-path-instructor-page/learning-path-instructor-page.component.scss'], +}) +export class LearningPathsTableComponent { + protected readonly faSpinner = faSpinner; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + private readonly modalService = inject(NgbModal); + + readonly courseId = input.required(); + + readonly isLoading = signal(false); + private readonly searchResults = signal | undefined>(undefined); + readonly learningPaths = computed(() => this.searchResults()?.resultsOnPage ?? []); + + readonly searchTerm = signal(''); + readonly page = signal(1); + private readonly sortingOrder = signal(SortingOrder.ASCENDING); + private readonly sortedColumn = signal(TableColumn.ID); + readonly pageSize = signal(100).asReadonly(); + readonly collectionSize = computed(() => (this.searchResults()?.numberOfPages ?? 1) * this.pageSize()); + + // Debounce the loadLearningPaths function to prevent multiple requests when the user types quickly + private readonly debounceLoadLearningPaths = BaseApiHttpService.debounce(this.loadLearningPaths.bind(this), 300); + + constructor() { + effect( + () => { + // Load learning paths whenever the courseId changes + const courseId = this.courseId(); + untracked(() => this.loadLearningPaths(courseId)); + }, + { allowSignalWrites: true }, + ); + } + + private async loadLearningPaths(courseId: number): Promise { + try { + this.isLoading.set(true); + const searchState = { + page: this.page(), + pageSize: this.pageSize(), + searchTerm: this.searchTerm(), + sortingOrder: this.sortingOrder(), + sortedColumn: this.sortedColumn(), + }; + const searchResults = await this.learningPathApiService.getLearningPathInformation(courseId, searchState); + this.searchResults.set(searchResults); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } + + search(searchTerm: string): void { + this.searchTerm.set(searchTerm); + this.page.set(1); + this.debounceLoadLearningPaths(this.courseId()); + } + + async setPage(pageNumber: number): Promise { + this.page.set(pageNumber); + await this.loadLearningPaths(this.courseId()); + } + + openCompetencyGraph(learningPathId: number): void { + CompetencyGraphModalComponent.openCompetencyGraphModal(this.modalService, learningPathId); + } +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html index c8a10304da15..fa2f5b75d63b 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html @@ -8,21 +8,19 @@ @if (!isLoading && health) {

- @if (health.status?.includes(HealthStatus.DISABLED)) { +
+
- -
- -
+
- } +
@if (health.status?.includes(HealthStatus.MISSING)) { } @@ -32,63 +30,61 @@

@if (health.status?.includes(HealthStatus.NO_RELATIONS)) { } - @if (!health.status?.includes(HealthStatus.DISABLED)) { -
-
- - - @if (searchLoading) { - - } -
- - - - - - - - +
+
+ + + @if (searchLoading) { + + } +
+
- # - - - - - - - - - - -
+ + + + + + + + + + + @for (learningPath of content.resultsOnPage; track trackId($index, learningPath)) { + + + + + + - - - @for (learningPath of content.resultsOnPage; track trackId($index, learningPath)) { - - - - - - - - } - -
+ # + + + + + + + + + + +
+ {{ learningPath.id }} + + + + + + + + +
- {{ learningPath.id }} - - - - - - - - -
-
- -
+ } + + +
+
- } +
} 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 e6f2eb495f5c..bff608a8cb93 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 @@ -135,10 +135,8 @@ export class LearningPathManagementComponent implements OnInit { .subscribe({ next: (res) => { this.health = res.body!; - if (!this.health.status?.includes(HealthStatus.DISABLED)) { - this.performSearch(this.sort, 0); - this.performSearch(this.search, 300); - } + this.performSearch(this.sort, 0); + this.performSearch(this.search, 300); }, error: (res: HttpErrorResponse) => onError(this.alertService, res), }); diff --git a/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.html b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.html new file mode 100644 index 000000000000..09ad272a5bbe --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.html @@ -0,0 +1,39 @@ +@if (isLoading()) { +
+
+ +
+
+} @else if (learningPathsEnabled()) { +
+
+ + + + +
+ +
+
+ +
+
+
+ +
+
+} @else { +
+
+

+ + +
+
+} diff --git a/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.scss b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.scss new file mode 100644 index 000000000000..be0c2625f1bd --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.scss @@ -0,0 +1,13 @@ +.enable-learning-paths-container { + max-width: 500px; + text-align: center; +} + +.learning-paths-container { + background: var(--bs-card-bg); +} + +.learning-paths-management-container { + max-height: 220px; + overflow-y: auto; +} diff --git a/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.ts b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.ts new file mode 100644 index 000000000000..1dab247186e1 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.ts @@ -0,0 +1,62 @@ +import { Component, computed, effect, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; +import { LearningPathsConfigurationComponent } from 'app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component'; +import { lastValueFrom, map } from 'rxjs'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { Course } from 'app/entities/course.model'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { LearningPathsStateComponent } from 'app/course/learning-paths/components/learning-paths-state/learning-paths-state.component'; +import { LearningPathsTableComponent } from 'app/course/learning-paths/components/learning-paths-table/learning-paths-table.component'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { LearningPathsAnalyticsComponent } from 'app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component'; + +@Component({ + selector: 'jhi-learning-path-instructor-page', + standalone: true, + imports: [LearningPathsConfigurationComponent, ArtemisSharedCommonModule, LearningPathsStateComponent, LearningPathsTableComponent, LearningPathsAnalyticsComponent], + templateUrl: './learning-path-instructor-page.component.html', + styleUrl: './learning-path-instructor-page.component.scss', +}) +export class LearningPathInstructorPageComponent { + private readonly activatedRoute = inject(ActivatedRoute); + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + private readonly courseManagementService = inject(CourseManagementService); + + readonly courseId = toSignal(this.activatedRoute.parent!.params.pipe(map((params) => Number(params.courseId))), { requireSync: true }); + private readonly course = signal(undefined); + readonly learningPathsEnabled = computed(() => this.course()?.learningPathsEnabled ?? false); + + readonly isLoading = signal(false); + + constructor() { + effect(() => this.loadCourse(this.courseId()), { allowSignalWrites: true }); + } + + private async loadCourse(courseId: number): Promise { + try { + this.isLoading.set(true); + const courseBody = await lastValueFrom(this.courseManagementService.findOneForDashboard(courseId)); + this.course.set(courseBody.body!); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } + + protected async enableLearningPaths(): Promise { + try { + this.isLoading.set(true); + await this.learningPathApiService.enableLearningPaths(this.courseId()); + this.course.update((course) => ({ ...course!, learningPathsEnabled: true })); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts b/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts index a5b5653ce442..898a2de8d301 100644 --- a/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts +++ b/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts @@ -2,6 +2,7 @@ import { HttpMethod } from 'app/admin/metrics/metrics.model'; import { inject } from '@angular/core'; import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'; import { lastValueFrom } from 'rxjs'; +import { SearchTermPageableSearch } from 'app/shared/table/pageable-table'; export abstract class BaseApiHttpService { private readonly httpClient: HttpClient = inject(HttpClient); @@ -67,6 +68,22 @@ export abstract class BaseApiHttpService { } } + /** + * Creates a `HttpParams` object from the given `SearchTermPageableSearch` object. + * @param pageable The pageable object to create the `HttpParams` object from. + * @protected + * + * @return The `HttpParams` object. + */ + protected createHttpSearchParams(pageable: SearchTermPageableSearch): HttpParams { + return new HttpParams() + .set('pageSize', String(pageable.pageSize)) + .set('page', String(pageable.page)) + .set('sortingOrder', pageable.sortingOrder) + .set('searchTerm', pageable.searchTerm) + .set('sortedColumn', pageable.sortedColumn); + } + /** * Constructs a `GET` request that interprets the body as JSON and * returns a Promise of an object of type `T`. @@ -185,4 +202,34 @@ export abstract class BaseApiHttpService { ): Promise { return await this.request(HttpMethod.Patch, url, { body: body, ...options }); } + + /** + * Constructs a `PUT` request that interprets the body as JSON and + * returns a Promise of an object of type `T`. + * + * @param url The endpoint URL excluding the base server url (/api). + * @param body The content to include in the body of the request. + * @param options The HTTP options to send with the request. + * @protected + * + * @return A `Promise` of type `Object` (T), + */ + protected async put( + url: string, + body?: any, + options?: { + headers?: + | HttpHeaders + | { + [header: string]: string | string[]; + }; + params?: + | HttpParams + | { + [param: string]: string | number | boolean | ReadonlyArray; + }; + }, + ): Promise { + return await this.request(HttpMethod.Put, url, { body: body, ...options }); + } } diff --git a/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts b/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts index 01a43398f1c8..951927be0f8e 100644 --- a/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts +++ b/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts @@ -4,12 +4,16 @@ import { LearningObjectType, LearningPathCompetencyDTO, LearningPathDTO, + LearningPathInformationDTO, LearningPathNavigationDTO, LearningPathNavigationObjectDTO, LearningPathNavigationOverviewDTO, + LearningPathsConfigurationDTO, } from 'app/entities/competency/learning-path.model'; import { HttpParams } from '@angular/common/http'; import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; +import { LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { SearchResult, SearchTermPageableSearch } from 'app/shared/table/pageable-table'; @Injectable({ providedIn: 'root', @@ -52,6 +56,10 @@ export class LearningPathApiService extends BaseApiHttpService { return await this.get(`learning-path/${learningPathId}/competency-graph`); } + async getLearningPathInstructorCompetencyGraph(courseId: number): Promise { + return await this.get(`courses/${courseId}/learning-path/competency-instructor-graph`); + } + async getLearningPathCompetencies(learningPathId: number): Promise { return await this.get(`learning-path/${learningPathId}/competencies`); } @@ -59,4 +67,29 @@ export class LearningPathApiService extends BaseApiHttpService { async getLearningPathCompetencyLearningObjects(learningPathId: number, competencyId: number): Promise { return await this.get(`learning-path/${learningPathId}/competencies/${competencyId}/learning-objects`); } + + async getLearningPathsConfiguration(courseId: number): Promise { + return await this.get(`courses/${courseId}/learning-paths/configuration`); + } + + async getLearningPathHealthStatus(courseId: number): Promise { + return await this.get(`courses/${courseId}/learning-path-health`); + } + + async updateLearningPathsConfiguration(courseId: number, updatedLearningPathsConfiguration: LearningPathsConfigurationDTO): Promise { + await this.put(`courses/${courseId}/learning-paths/configuration`, updatedLearningPathsConfiguration); + } + + async enableLearningPaths(courseId: number): Promise { + await this.put(`courses/${courseId}/learning-paths/enable`); + } + + async generateMissingLearningPaths(courseId: number): Promise { + await this.put(`courses/${courseId}/learning-paths/generate-missing`); + } + + async getLearningPathInformation(courseId: number, pageable: SearchTermPageableSearch): Promise> { + const params = this.createHttpSearchParams(pageable); + return await this.get>(`courses/${courseId}/learning-paths`, { params }); + } } diff --git a/src/main/webapp/app/course/manage/course-management.route.ts b/src/main/webapp/app/course/manage/course-management.route.ts index ad4507123d1a..c85789f6d74e 100644 --- a/src/main/webapp/app/course/manage/course-management.route.ts +++ b/src/main/webapp/app/course/manage/course-management.route.ts @@ -22,7 +22,6 @@ import { CreateTutorialGroupsConfigurationComponent } from 'app/course/tutorial- import { CourseLtiConfigurationComponent } from 'app/course/manage/course-lti-configuration/course-lti-configuration.component'; import { EditCourseLtiConfigurationComponent } from 'app/course/manage/course-lti-configuration/edit-course-lti-configuration.component'; import { CourseManagementTabBarComponent } from 'app/course/manage/course-management-tab-bar/course-management-tab-bar.component'; -import { LearningPathManagementComponent } from 'app/course/learning-paths/learning-path-management/learning-path-management.component'; import { PendingChangesGuard } from 'app/shared/guard/pending-changes.guard'; import { BuildQueueComponent } from 'app/localci/build-queue/build-queue.component'; import { ImportCompetenciesComponent } from 'app/course/competencies/import/import-competencies.component'; @@ -33,6 +32,7 @@ import { ImportPrerequisitesComponent } from 'app/course/competencies/import/imp import { CreatePrerequisiteComponent } from 'app/course/competencies/create/create-prerequisite.component'; import { EditPrerequisiteComponent } from 'app/course/competencies/edit/edit-prerequisite.component'; import { CourseImportStandardizedPrerequisitesComponent } from 'app/course/competencies/import-standardized-competencies/course-import-standardized-prerequisites.component'; +import { LearningPathInstructorPageComponent } from 'app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component'; import { FaqComponent } from 'app/faq/faq.component'; import { FaqUpdateComponent } from 'app/faq/faq-update.component'; import { FaqResolve } from 'app/faq/faq.routes'; @@ -321,7 +321,7 @@ export const courseManagementState: Routes = [ }, { path: 'learning-path-management', - component: LearningPathManagementComponent, + component: LearningPathInstructorPageComponent, data: { authorities: [Authority.INSTRUCTOR, Authority.ADMIN], pageTitle: 'artemisApp.learningPath.manageLearningPaths.title', diff --git a/src/main/webapp/app/entities/competency/learning-path-health.model.ts b/src/main/webapp/app/entities/competency/learning-path-health.model.ts index 1cbcb13ba367..803dabf9eca2 100644 --- a/src/main/webapp/app/entities/competency/learning-path-health.model.ts +++ b/src/main/webapp/app/entities/competency/learning-path-health.model.ts @@ -1,5 +1,5 @@ export class LearningPathHealthDTO { - public status?: HealthStatus[]; + public status: HealthStatus[] = []; public missingLearningPaths?: number; constructor(status: HealthStatus[]) { @@ -8,18 +8,12 @@ export class LearningPathHealthDTO { } export enum HealthStatus { - OK = 'OK', - DISABLED = 'DISABLED', MISSING = 'MISSING', NO_COMPETENCIES = 'NO_COMPETENCIES', NO_RELATIONS = 'NO_RELATIONS', } function getWarningTranslation(status: HealthStatus, element: string) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - const translation = { [HealthStatus.MISSING]: 'missing', [HealthStatus.NO_COMPETENCIES]: 'noCompetencies', @@ -29,33 +23,17 @@ function getWarningTranslation(status: HealthStatus, element: string) { } export function getWarningTitle(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'title'); } export function getWarningBody(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'body'); } export function getWarningAction(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'action'); } export function getWarningHint(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'hint'); } 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 2c55868d0af0..576afd4e75a5 100644 --- a/src/main/webapp/app/entities/competency/learning-path.model.ts +++ b/src/main/webapp/app/entities/competency/learning-path.model.ts @@ -16,9 +16,9 @@ export class LearningPath implements BaseEntity { } export class LearningPathInformationDTO { - public id?: number; - public user?: UserNameAndLoginDTO; - public progress?: number; + public id: number; + public user: UserNameAndLoginDTO; + public progress: number; } export enum LearningObjectType { @@ -58,8 +58,13 @@ export interface LearningPathNavigationOverviewDTO { learningObjects: LearningPathNavigationObjectDTO[]; } +export interface LearningPathsConfigurationDTO { + includeAllGradedExercises: boolean; +} + export enum CompetencyGraphNodeValueType { MASTERY_PROGRESS = 'MASTERY_PROGRESS', + AVERAGE_MASTERY_PROGRESS = 'AVERAGE_MASTERY_PROGRESS', } export interface CompetencyGraphNodeDTO { diff --git a/src/main/webapp/i18n/de/learningPathManagement.json b/src/main/webapp/i18n/de/learningPathManagement.json new file mode 100644 index 000000000000..b21ec355fbd3 --- /dev/null +++ b/src/main/webapp/i18n/de/learningPathManagement.json @@ -0,0 +1,60 @@ +{ + "artemisApp": { + "learningPathManagement": { + "learningPathsDisabled": { + "title": "Willkommen bei der Lernpfadverwaltung!", + "description": "Die Lernpfadfunktion ist derzeit für diesen Kurs deaktiviert. Mit dieser Funktion können Kursteilnehmer einen personalisierten Lernpfad verfolgen, der auf den definierten Kompetenzen basiert. Jeder Lernpfad wird auf der Grundlage des individuellen Fortschritts des Teilnehmers, der Beziehungen zwischen den Kompetenzen und der festgelegten Fälligkeitsdaten erstellt. Mit dem folgenden Button kann die Lernpfadfunktion aktiviert werden.", + "buttonLabel": "Lernpfade aktivieren" + }, + "learningPathsState": { + "title": "Status", + "emptyState": "Alles ist eingerichtet und bereit!", + "refreshButtonLabel": "Aktualisieren", + "type": { + "missing": { + "title": "Fehlende Lernpfade", + "description": "Einige Studierende haben ihre Lernpfade noch nicht generiert. Ihre Lernpfade werden erst erstellt, wenn sie ihren Lernpfad zum ersten Mal anfordern.", + "actionButton": "Lernpfad generieren", + "successAlert": "Lernpfade wurden erfolgreich generiert." + }, + "noCompetencies": { + "title": "Keine Kompetenzen", + "description": "Es existieren noch keine Kompetenzen für diesen Kurs. Bitte erstelle Kompetenzen für diesen Kurs.", + "actionButton": "Kompetenzen zuweisen" + }, + "noRelations": { + "title": "Keine Kompetenzbeziehungen", + "description": "Einigen Kompetenzen wurden noch keine Beziehungen zugewiesen. Bitte weise diesen Kompetenzen Beziehungen zu.", + "actionButton": "Beziehungen zuweisen" + } + } + }, + "learningPathsConfiguration": { + "title": "Konfiguration", + "saveButtonLabel": "Speichern", + "editButtonLabel": "Bearbeiten", + "configuration": { + "includeAllGradedExercises": "Alle bewerteten Aufgaben einbeziehen", + "includeAllGradedExercisesToolTip": "Die Lernpfade werden alle Aufgaben enthalten, die in die Kursbewertung einfließen. Dadurch können Studierende keine wichtigen Aufgaben im Lernpfad übersehen." + }, + "saveSuccess": "Die Konfiguration wurde erfolgreich gespeichert." + }, + "learningPathsTable": { + "title": "Individuelle Lernpfade", + "searchPlaceholder": "Suchen", + "columnLabel": { + "name": "Name", + "login": "Login", + "progress": "Fortschritt" + }, + "noResults": "Keine Lernpfade gefunden!" + }, + "learningPathsAnalytics": { + "title": "Analyse", + "graphSelection": { + "AVERAGE_MASTERY_PROGRESS": "Ø Beherrschungsfortschritt" + } + } + } + } +} diff --git a/src/main/webapp/i18n/en/learningPathManagement.json b/src/main/webapp/i18n/en/learningPathManagement.json new file mode 100644 index 000000000000..0817497e0f23 --- /dev/null +++ b/src/main/webapp/i18n/en/learningPathManagement.json @@ -0,0 +1,60 @@ +{ + "artemisApp": { + "learningPathManagement": { + "learningPathsDisabled": { + "title": "Welcome to the learning path management!", + "description": "The learning path feature is currently disabled for this course. This feature enables students to follow a personalized learning path based on the competencies you have defined. Each learning path is generated based on the student's individual progress, the relationships between competencies, and the specified soft due dates. You can enable the learning path feature by clicking the button below.", + "buttonLabel": "Enable learning paths" + }, + "learningPathsState": { + "title": "State", + "emptyState": "Everything is set up and ready to go!", + "refreshButtonLabel": "Refresh", + "type": { + "missing": { + "title": "Missing learning paths", + "description": "Some students have not generated their learning paths yet. Their learning paths will be created once they request their learning path for the first time.", + "actionButton": "Generate learning paths", + "successAlert": "Learning paths have been generated successfully." + }, + "noCompetencies": { + "title": "No competencies", + "description": "Some students have not been assigned any competencies yet. Please assign competencies to these students.", + "actionButton": "Assign competencies" + }, + "noRelations": { + "title": "No relations", + "description": "Some competencies have not been assigned any relations yet. Please assign relations to these competencies.", + "actionButton": "Assign relations" + } + } + }, + "learningPathsConfiguration": { + "title": "Configuration", + "saveButtonLabel": "Save", + "editButtonLabel": "Edit", + "configuration": { + "includeAllGradedExercises": "Include all graded exercises", + "includeAllGradedExercisesToolTip": "The learning paths will include all exercises that are included in the course score. This way, students cannot miss important exercises in the learning path." + }, + "saveSuccess": "The configuration has been saved successfully." + }, + "learningPathsTable": { + "title": "Individual learning paths", + "searchPlaceholder": "Search", + "columnLabel": { + "name": "Name", + "login": "Login", + "progress": "Progress" + }, + "noResults": "No learning paths found!" + }, + "learningPathsAnalytics": { + "title": "Analytics", + "graphSelection": { + "AVERAGE_MASTERY_PROGRESS": "Ø Mastery progress" + } + } + } + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/learningpath/LearningPathIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/learningpath/LearningPathIntegrationTest.java index 9c55add49a9d..b217564b469f 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/learningpath/LearningPathIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/learningpath/LearningPathIntegrationTest.java @@ -72,6 +72,8 @@ class LearningPathIntegrationTest extends AbstractAtlasIntegrationTest { private static final String STUDENT2_OF_COURSE = TEST_PREFIX + "student2"; + private static final String SECOND_STUDENT_OF_COURSE = TEST_PREFIX + "student2"; + private static final String TUTOR_OF_COURSE = TEST_PREFIX + "tutor1"; private static final String EDITOR_OF_COURSE = TEST_PREFIX + "editor1"; @@ -131,6 +133,7 @@ private void testAllPreAuthorize() throws Exception { final var search = pageableSearchUtilService.configureSearch(""); request.getSearchResult("/api/courses/" + course.getId() + "/learning-paths", HttpStatus.FORBIDDEN, LearningPath.class, pageableSearchUtilService.searchMapping(search)); request.get("/api/courses/" + course.getId() + "/learning-path-health", HttpStatus.FORBIDDEN, LearningPathHealthDTO.class); + request.get("/api/courses/" + course.getId() + "/learning-path/competency-instructor-graph", HttpStatus.FORBIDDEN, NgxLearningPathDTO.class); } private void enableLearningPathsRESTCall(Course course) throws Exception { @@ -397,7 +400,7 @@ void testGetLearningPathWithOwner() throws Exception { @WithMockUser(username = STUDENT1_OF_COURSE, roles = "USER") void testGetLearningPathOfOtherUser() throws Exception { course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); - final var otherStudent = userTestRepository.findOneByLogin(TEST_PREFIX + "student2").orElseThrow(); + final var otherStudent = userTestRepository.findOneByLogin(SECOND_STUDENT_OF_COURSE).orElseThrow(); final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), otherStudent.getId()); request.get("/api/learning-path/" + learningPath.getId(), HttpStatus.FORBIDDEN, NgxLearningPathDTO.class); } @@ -406,7 +409,7 @@ void testGetLearningPathOfOtherUser() throws Exception { @WithMockUser(username = STUDENT1_OF_COURSE, roles = "USER") void testGetLearningPathCompetencyGraphOfOtherUser() throws Exception { course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); - final var otherStudent = userTestRepository.findOneByLogin(TEST_PREFIX + "student2").orElseThrow(); + final var otherStudent = userTestRepository.findOneByLogin(SECOND_STUDENT_OF_COURSE).orElseThrow(); final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), otherStudent.getId()); request.get("/api/learning-path/" + learningPath.getId() + "/competency-graph", HttpStatus.FORBIDDEN, LearningPathCompetencyGraphDTO.class); } @@ -453,7 +456,7 @@ void testGetLearningPathNgxForLearningPathsDisabled(LearningPathResource.NgxRequ @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") @EnumSource(LearningPathResource.NgxRequestType.class) - @WithMockUser(username = TEST_PREFIX + "student2", roles = "USER") + @WithMockUser(username = SECOND_STUDENT_OF_COURSE, roles = "USER") void testGetLearningPathNgxForOtherStudent(LearningPathResource.NgxRequestType type) throws Exception { course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); final var student = userTestRepository.findOneByLogin(STUDENT1_OF_COURSE).orElseThrow(); @@ -617,7 +620,7 @@ void shouldStartLearningPath() throws Exception { } @Test - @WithMockUser(username = TEST_PREFIX + "student2", roles = "USER") + @WithMockUser(username = SECOND_STUDENT_OF_COURSE, roles = "USER") void testGetCompetencyProgressForLearningPathByOtherStudent() throws Exception { course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); final var student = userTestRepository.findOneByLogin(STUDENT1_OF_COURSE).orElseThrow(); diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/LearningPathServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/LearningPathServiceTest.java index 65143ebdf769..471d3752c728 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/LearningPathServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/LearningPathServiceTest.java @@ -110,24 +110,6 @@ void setup() { course = courseRepository.save(course); } - @Test - void testHealthStatusDisabled() { - var healthStatus = learningPathService.getHealthStatusForCourse(course); - assertThat(healthStatus.status()).containsExactly(LearningPathHealthDTO.HealthStatus.DISABLED); - assertThat(healthStatus.missingLearningPaths()).isNull(); - } - - @Test - void testHealthStatusOK() { - final var competency1 = competencyUtilService.createCompetency(course); - final var competency2 = competencyUtilService.createCompetency(course); - competencyUtilService.addRelation(competency1, RelationType.MATCHES, competency2); - course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); - var healthStatus = learningPathService.getHealthStatusForCourse(course); - assertThat(healthStatus.status()).containsExactly(LearningPathHealthDTO.HealthStatus.OK); - assertThat(healthStatus.missingLearningPaths()).isNull(); - } - @Test void testHealthStatusMissing() { final var competency1 = competencyUtilService.createCompetency(course); diff --git a/src/test/javascript/spec/component/learning-paths/components/learning-paths-analytics.component.spec.ts b/src/test/javascript/spec/component/learning-paths/components/learning-paths-analytics.component.spec.ts new file mode 100644 index 000000000000..fe54e94bb83d --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/components/learning-paths-analytics.component.spec.ts @@ -0,0 +1,95 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LearningPathsAnalyticsComponent } from 'app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { TranslateService } from '@ngx-translate/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { CompetencyGraphDTO, CompetencyGraphEdgeDTO, CompetencyGraphNodeDTO, CompetencyGraphNodeValueType } from 'app/entities/competency/learning-path.model'; + +describe('LearningPathsAnalyticsComponent', () => { + let component: LearningPathsAnalyticsComponent; + let fixture: ComponentFixture; + let learningPathApiService: LearningPathApiService; + let alertService: AlertService; + let getInstructorCompetencyGraphSpy: jest.SpyInstance; + + const courseId = 1; + + const competencyGraph = { + nodes: [ + { + id: '1', + label: 'Node 1', + valueType: CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS, + value: 12, + } as CompetencyGraphNodeDTO, + { + id: '2', + label: 'Node 2', + valueType: CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS, + value: 0, + } as CompetencyGraphNodeDTO, + ], + edges: [ + { + source: '1', + target: '2', + } as CompetencyGraphEdgeDTO, + ], + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LearningPathsAnalyticsComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { provide: AlertService, useClass: MockAlertService }, + ], + }).compileComponents(); + + learningPathApiService = TestBed.inject(LearningPathApiService); + alertService = TestBed.inject(AlertService); + + getInstructorCompetencyGraphSpy = jest.spyOn(learningPathApiService, 'getLearningPathInstructorCompetencyGraph').mockResolvedValue(competencyGraph); + + fixture = TestBed.createComponent(LearningPathsAnalyticsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('courseId', courseId); + }); + + it('should load instructor competency graph', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(getInstructorCompetencyGraphSpy).toHaveBeenCalledExactlyOnceWith(courseId); + expect(component.instructorCompetencyGraph()).toEqual(competencyGraph); + }); + + it('should set isLoading correctly', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should show error on load instructor competency graph', async () => { + const alertServiceErrorSpy = jest.spyOn(alertService, 'addAlert'); + getInstructorCompetencyGraphSpy.mockRejectedValue(new Error('Error')); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/components/learning-paths-configuration.component.spec.ts b/src/test/javascript/spec/component/learning-paths/components/learning-paths-configuration.component.spec.ts new file mode 100644 index 000000000000..220681f972f8 --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/components/learning-paths-configuration.component.spec.ts @@ -0,0 +1,147 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LearningPathsConfigurationComponent } from 'app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { LearningPathsConfigurationDTO } from 'app/entities/competency/learning-path.model'; + +describe('LearningPathsConfigurationComponent', () => { + let component: LearningPathsConfigurationComponent; + let fixture: ComponentFixture; + let learningPathApiService: LearningPathApiService; + let alertService: AlertService; + let getLearningPathsConfigurationSpy: jest.SpyInstance; + + const courseId = 1; + + const learningPathsConfiguration = { + includeAllGradedExercises: true, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LearningPathsConfigurationComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { provide: AlertService, useClass: MockAlertService }, + ], + }).compileComponents(); + + learningPathApiService = TestBed.inject(LearningPathApiService); + alertService = TestBed.inject(AlertService); + + getLearningPathsConfigurationSpy = jest.spyOn(learningPathApiService, 'getLearningPathsConfiguration').mockResolvedValue(learningPathsConfiguration); + + fixture = TestBed.createComponent(LearningPathsConfigurationComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('courseId', courseId); + }); + + it('should load learning paths configuration', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const includeAllExercisesCheckBox = fixture.nativeElement.querySelector('#include-all-graded-exercises-checkbox'); + + fixture.detectChanges(); + + expect(includeAllExercisesCheckBox.checked).toEqual(learningPathsConfiguration.includeAllGradedExercises); + expect(component.includeAllGradedExercisesEnabled()).toEqual(learningPathsConfiguration.includeAllGradedExercises); + expect(getLearningPathsConfigurationSpy).toHaveBeenCalledExactlyOnceWith(courseId); + }); + + it('should show error on load learning paths configuration', async () => { + const alertServiceErrorSpy = jest.spyOn(alertService, 'addAlert'); + getLearningPathsConfigurationSpy.mockRejectedValue(new Error('Error')); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); + + it('should set isLoading correctly', async () => { + const isLoadingSpy = jest.spyOn(component.isConfigLoading, 'set'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should enable edit mode', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const includeAllExercisesCheckBox = fixture.nativeElement.querySelector('#include-all-graded-exercises-checkbox'); + expect(includeAllExercisesCheckBox.disabled).toBeTrue(); + + await enableEditMode(); + + expect(includeAllExercisesCheckBox.disabled).toBeFalse(); + + const saveButton = fixture.nativeElement.querySelector('#save-learning-paths-configuration-button'); + expect(saveButton).not.toBeNull(); + expect(component.isEditMode()).toBeTrue(); + }); + + it('should toggle include all graded exercises', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + await enableEditMode(); + + const includeAllExercisesCheckBox = fixture.nativeElement.querySelector('#include-all-graded-exercises-checkbox'); + includeAllExercisesCheckBox.click(); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(includeAllExercisesCheckBox.checked).toBe(!learningPathsConfiguration.includeAllGradedExercises); + expect(component.includeAllGradedExercisesEnabled()).toBe(!learningPathsConfiguration.includeAllGradedExercises); + }); + + it('should save learning paths configuration', async () => { + const updateLearningPathsConfigurationSpy = jest.spyOn(learningPathApiService, 'updateLearningPathsConfiguration').mockResolvedValue(); + const alertServiceSuccessSpy = jest.spyOn(alertService, 'success'); + + fixture.detectChanges(); + await fixture.whenStable(); + + await enableEditMode(); + + const includeAllExercisesCheckBox = fixture.nativeElement.querySelector('#include-all-graded-exercises-checkbox'); + includeAllExercisesCheckBox.click(); + + const saveButton = fixture.nativeElement.querySelector('#save-learning-paths-configuration-button'); + saveButton.click(); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(updateLearningPathsConfigurationSpy).toHaveBeenCalledExactlyOnceWith(courseId, { + ...learningPathsConfiguration, + includeAllGradedExercises: !learningPathsConfiguration.includeAllGradedExercises, + }); + expect(alertServiceSuccessSpy).toHaveBeenCalledOnce(); + expect(component.isEditMode()).toBeFalse(); + }); + + async function enableEditMode(): Promise { + const editButton = fixture.nativeElement.querySelector('#edit-learning-paths-configuration-button'); + editButton.click(); + + fixture.detectChanges(); + await fixture.whenStable(); + } +}); diff --git a/src/test/javascript/spec/component/learning-paths/components/learning-paths-state.component.spec.ts b/src/test/javascript/spec/component/learning-paths/components/learning-paths-state.component.spec.ts new file mode 100644 index 000000000000..0193ae9a129f --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/components/learning-paths-state.component.spec.ts @@ -0,0 +1,145 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LearningPathsStateComponent } from 'app/course/learning-paths/components/learning-paths-state/learning-paths-state.component'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { provideHttpClient } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of } from 'rxjs'; +import { HealthStatus, LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { MockRouter } from '../../../helpers/mocks/mock-router'; + +describe('LearningPathsStateComponent', () => { + let component: LearningPathsStateComponent; + let fixture: ComponentFixture; + let learningPathApiService: LearningPathApiService; + let alertService: AlertService; + let router: Router; + let getLearningPathHealthStatusSpy: jest.SpyInstance; + + const courseId = 1; + + const learningPathHealth = { + missingLearningPaths: 1, + status: [HealthStatus.MISSING, HealthStatus.NO_COMPETENCIES, HealthStatus.NO_RELATIONS], + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LearningPathsStateComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { provide: AlertService, useClass: MockAlertService }, + { + provide: ActivatedRoute, + useValue: { + parent: { + parent: { + params: of({ + courseId: courseId, + }), + }, + }, + }, + }, + { + provide: Router, + useClass: MockRouter, + }, + ], + }).compileComponents(); + + learningPathApiService = TestBed.inject(LearningPathApiService); + alertService = TestBed.inject(AlertService); + router = TestBed.inject(Router); + + getLearningPathHealthStatusSpy = jest.spyOn(learningPathApiService, 'getLearningPathHealthStatus').mockResolvedValue(learningPathHealth); + + fixture = TestBed.createComponent(LearningPathsStateComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('courseId', courseId); + }); + + it('should load learning path health status', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const learningPathsStateContainer = fixture.nativeElement.querySelectorAll('.learning-paths-state-container'); + + expect(learningPathsStateContainer).toHaveLength(learningPathHealth.status.length); + expect(getLearningPathHealthStatusSpy).toHaveBeenCalledExactlyOnceWith(courseId); + expect(component.learningPathHealthState()).toEqual(learningPathHealth.status); + }); + + it('should set isLoading correctly', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should show error when loading fails', async () => { + jest.spyOn(learningPathApiService, 'getLearningPathHealthStatus').mockRejectedValue(new Error('Error loading learning path health status')); + const onErrorSpy = jest.spyOn(alertService, 'addAlert'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(onErrorSpy).toHaveBeenCalledOnce(); + }); + + it.each([HealthStatus.NO_COMPETENCIES, HealthStatus.NO_RELATIONS])('should navigate to competencies page on %s status', async (status) => { + const navigateSpy = jest.spyOn(router, 'navigate'); + getLearningPathHealthStatusSpy.mockResolvedValue({ ...learningPathHealth, status: [status] }); + + await clickHealthStateButton(`#health-state-button-${status}`); + + expect(navigateSpy).toHaveBeenCalledExactlyOnceWith(['../competency-management'], { relativeTo: TestBed.inject(ActivatedRoute) }); + }); + + it('should generate missing learning paths', async () => { + const generateMissingLearningPathsSpy = jest.spyOn(learningPathApiService, 'generateMissingLearningPaths').mockResolvedValue(); + const successSpy = jest.spyOn(alertService, 'success'); + getLearningPathHealthStatusSpy.mockResolvedValue({ ...learningPathHealth, status: [HealthStatus.MISSING] }); + + await clickHealthStateButton(`#health-state-button-${HealthStatus.MISSING}`); + + expect(generateMissingLearningPathsSpy).toHaveBeenCalledExactlyOnceWith(courseId); + expect(successSpy).toHaveBeenCalledOnce(); + expect(getLearningPathHealthStatusSpy).toHaveBeenNthCalledWith(2, courseId); + }); + + it('should show error when generating missing learning paths fails', async () => { + jest.spyOn(learningPathApiService, 'generateMissingLearningPaths').mockRejectedValue(new Error('Error generating missing learning paths')); + const onErrorSpy = jest.spyOn(alertService, 'addAlert'); + getLearningPathHealthStatusSpy.mockResolvedValue({ ...learningPathHealth, status: [HealthStatus.MISSING] }); + + await clickHealthStateButton(`#health-state-button-${HealthStatus.MISSING}`); + + expect(onErrorSpy).toHaveBeenCalledOnce(); + }); + + async function clickHealthStateButton(selector: string) { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const healthStateButton = fixture.nativeElement.querySelector(selector); + healthStateButton.click(); + + fixture.detectChanges(); + await fixture.whenStable(); + } +}); diff --git a/src/test/javascript/spec/component/learning-paths/components/learning-paths-table.component.spec.ts b/src/test/javascript/spec/component/learning-paths/components/learning-paths-table.component.spec.ts new file mode 100644 index 000000000000..66485d809779 --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/components/learning-paths-table.component.spec.ts @@ -0,0 +1,147 @@ +import '@angular/localize/init'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LearningPathsTableComponent } from 'app/course/learning-paths/components/learning-paths-table/learning-paths-table.component'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { TranslateService } from '@ngx-translate/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; +import { SearchResult, SearchTermPageableSearch } from 'app/shared/table/pageable-table'; +import { By } from '@angular/platform-browser'; + +describe('LearningPathsTableComponent', () => { + let component: LearningPathsTableComponent; + let fixture: ComponentFixture; + let learningPathApiService: LearningPathApiService; + let alertService: AlertService; + let getLearningPathInformationSpy: jest.SpyInstance; + + const courseId = 1; + + const searchResults = >{ + numberOfPages: 2, + resultsOnPage: generateResults(0, 100), + }; + + const pageable = { + page: 1, + pageSize: 100, + searchTerm: '', + sortingOrder: 'ASCENDING', + sortedColumn: 'ID', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LearningPathsTableComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { provide: AlertService, useClass: MockAlertService }, + ], + }).compileComponents(); + + learningPathApiService = TestBed.inject(LearningPathApiService); + alertService = TestBed.inject(AlertService); + + getLearningPathInformationSpy = jest.spyOn(learningPathApiService, 'getLearningPathInformation').mockResolvedValue(searchResults); + + fixture = TestBed.createComponent(LearningPathsTableComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('courseId', courseId); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should load learning paths', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const learningPathRows = fixture.nativeElement.querySelectorAll('tr'); + + expect(getLearningPathInformationSpy).toHaveBeenCalledExactlyOnceWith(courseId, pageable); + + expect(component.learningPaths()).toEqual(searchResults.resultsOnPage); + expect(learningPathRows).toHaveLength(searchResults.resultsOnPage.length + 1); + expect(component.collectionSize()).toBe(searchResults.resultsOnPage.length * searchResults.numberOfPages); + }); + + it('should open competency graph modal', async () => { + const learningPathId = 1; + const openCompetencyGraphSpy = jest.spyOn(component, 'openCompetencyGraph'); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const learningPathIdButton = fixture.debugElement.query(By.css(`#open-competency-graph-button-${learningPathId}`)); + learningPathIdButton.nativeElement.click(); + expect(openCompetencyGraphSpy).toHaveBeenCalledExactlyOnceWith(1); + }); + + it('should change page', async () => { + const onPageChangeSpy = jest.spyOn(component, 'setPage'); + + fixture.detectChanges(); + await fixture.whenStable(); + + await component.setPage(2); + + expect(onPageChangeSpy).toHaveBeenLastCalledWith(2); + expect(getLearningPathInformationSpy).toHaveBeenLastCalledWith(courseId, { ...pageable, page: 2 }); + }); + + it('should search for learning paths when the search term changes', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const searchField = fixture.debugElement.query(By.css('#learning-path-search')); + const searchPageable = { ...pageable, searchTerm: 'Search Term' }; + searchField.nativeElement.value = 'Search Term'; + searchField.nativeElement.dispatchEvent(new Event('input')); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(getLearningPathInformationSpy).toHaveBeenLastCalledWith(courseId, searchPageable); + }); + + it('should show error message when loading learning paths fails', async () => { + getLearningPathInformationSpy.mockRejectedValue(new Error('Error loading learning paths')); + const alertServiceErrorSpy = jest.spyOn(alertService, 'addAlert'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); + + it('should set isLoading correctly', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + function generateResults(start: number, end: number): LearningPathInformationDTO[] { + return Array.from({ length: end - start }, (_, i) => ({ + id: i + start, + user: { name: `User ${i + start}`, login: `user${i + start}` }, + progress: i + start, + })); + } +}); diff --git a/src/test/javascript/spec/component/learning-paths/management/learning-path-management.component.spec.ts b/src/test/javascript/spec/component/learning-paths/management/learning-path-management.component.spec.ts index d432116e8ae0..884544d93695 100644 --- a/src/test/javascript/spec/component/learning-paths/management/learning-path-management.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/management/learning-path-management.component.spec.ts @@ -3,7 +3,7 @@ import { LearningPathManagementComponent, TableColumn } from 'app/course/learnin import { LearningPathPagingService } from 'app/course/learning-paths/learning-path-paging.service'; import { SortService } from 'app/shared/service/sort.service'; import { SearchResult, SearchTermPageableSearch, SortingOrder } from 'app/shared/table/pageable-table'; -import { LearningPath } from 'app/entities/competency/learning-path.model'; +import { LearningPath, LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; import { ArtemisTestModule } from '../../../test.module'; import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; import { ButtonComponent } from 'app/shared/components/button.component'; @@ -97,7 +97,7 @@ describe('LearningPathManagementComponent', () => { searchStub.mockReturnValue(of(searchResult)); enableLearningPathsStub.mockReturnValue(of(new HttpResponse())); generateMissingLearningPathsForCourseStub.mockReturnValue(of(new HttpResponse())); - health = new LearningPathHealthDTO([HealthStatus.OK]); + health = new LearningPathHealthDTO([]); getHealthStatusForCourseStub.mockReturnValue(of(new HttpResponse({ body: health }))); }); @@ -128,19 +128,6 @@ describe('LearningPathManagementComponent', () => { expect(alertServiceStub).toHaveBeenCalledOnce(); })); - it('should enable learning paths and load data', fakeAsync(() => { - const healthDisabled = new LearningPathHealthDTO([HealthStatus.DISABLED]); - getHealthStatusForCourseStub.mockReturnValueOnce(of(new HttpResponse({ body: healthDisabled }))).mockReturnValueOnce(of(new HttpResponse({ body: health }))); - fixture.detectChanges(); - comp.ngOnInit(); - expect(comp.health).toEqual(healthDisabled); - comp.enableLearningPaths(); - expect(enableLearningPathsStub).toHaveBeenCalledOnce(); - expect(enableLearningPathsStub).toHaveBeenCalledWith(courseId); - expect(getHealthStatusForCourseStub).toHaveBeenCalledTimes(3); - expect(comp.health).toEqual(health); - })); - it('should alert error if enable learning paths fails', fakeAsync(() => { const error = { status: 404 }; enableLearningPathsStub.mockReturnValue(throwError(() => new HttpErrorResponse(error))); @@ -218,6 +205,6 @@ describe('LearningPathManagementComponent', () => { })); it('should return learning path id', () => { - expect(comp.trackId(0, learningPath)).toEqual(learningPath.id); + expect(comp.trackId(0, { id: 2 })).toEqual(learningPath.id); }); }); diff --git a/src/test/javascript/spec/component/learning-paths/pages/learning-path-instructor-page.component.spec.ts b/src/test/javascript/spec/component/learning-paths/pages/learning-path-instructor-page.component.spec.ts new file mode 100644 index 000000000000..500d5db33716 --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/pages/learning-path-instructor-page.component.spec.ts @@ -0,0 +1,145 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LearningPathInstructorPageComponent } from 'app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { HttpErrorResponse, HttpResponse, provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { ArtemisTestModule } from '../../../test.module'; +import { Course } from 'app/entities/course.model'; + +describe('LearningPathInstructorPageComponent', () => { + let component: LearningPathInstructorPageComponent; + let fixture: ComponentFixture; + let learningPathApiService: LearningPathApiService; + let alertService: AlertService; + let courseManagementService: CourseManagementService; + let getCourseSpy: jest.SpyInstance; + + const courseId = 1; + + const course = { + id: 1, + learningPathsEnabled: false, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ArtemisTestModule, LearningPathInstructorPageComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: ActivatedRoute, + useValue: { + parent: { + params: of({ + courseId: courseId, + }), + }, + }, + }, + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { provide: AlertService, useClass: MockAlertService }, + ], + }).compileComponents(); + + learningPathApiService = TestBed.inject(LearningPathApiService); + alertService = TestBed.inject(AlertService); + courseManagementService = TestBed.inject(CourseManagementService); + + getCourseSpy = jest.spyOn(courseManagementService, 'findOneForDashboard').mockReturnValue( + of( + new HttpResponse({ + body: course, + }), + ), + ); + + fixture = TestBed.createComponent(LearningPathInstructorPageComponent); + component = fixture.componentInstance; + }); + + it('should load course', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(getCourseSpy).toHaveBeenCalledExactlyOnceWith(courseId); + expect(component.learningPathsEnabled()).toBe(course.learningPathsEnabled); + }); + + it('should show error on load course', async () => { + const alertServiceErrorSpy = jest.spyOn(alertService, 'addAlert'); + getCourseSpy.mockRejectedValue(new HttpErrorResponse({ error: 'Error' })); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); + + it('should set isLoading correctly on course loading', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should show enable learning paths button if not enabled', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const enableLearningPathsButton = fixture.nativeElement.querySelector('#enable-learning-paths-button'); + expect(enableLearningPathsButton).toBeDefined(); + }); + + it('should enable learning paths', async () => { + const enableLearningPathsSpy = jest.spyOn(learningPathApiService, 'enableLearningPaths').mockResolvedValue(); + + await clickEnableLearningPathsButton(); + + expect(enableLearningPathsSpy).toHaveBeenCalledExactlyOnceWith(courseId); + expect(component.learningPathsEnabled()).toBeTrue(); + }); + + it('should show error on enable learning paths', async () => { + const alertServiceErrorSpy = jest.spyOn(alertService, 'addAlert'); + jest.spyOn(learningPathApiService, 'enableLearningPaths').mockRejectedValue(new Error('Error')); + + await clickEnableLearningPathsButton(); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); + + it('should set isLoading correctly on enable learning paths', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + await clickEnableLearningPathsButton(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + async function clickEnableLearningPathsButton(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + + const enableLearningPathsButton = fixture.nativeElement.querySelector('#enable-learning-paths-button'); + enableLearningPathsButton.click(); + + fixture.detectChanges(); + await fixture.whenStable(); + } +}); diff --git a/src/test/javascript/spec/component/learning-paths/pages/learning-path-student-page.component.spec.ts b/src/test/javascript/spec/component/learning-paths/pages/learning-path-student-page.component.spec.ts index 15f0dc14b768..2bebd4f5df76 100644 --- a/src/test/javascript/spec/component/learning-paths/pages/learning-path-student-page.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/pages/learning-path-student-page.component.spec.ts @@ -78,18 +78,13 @@ describe('LearningPathStudentPageComponent', () => { jest.restoreAllMocks(); }); - it('should initialize', () => { - expect(component).toBeTruthy(); - expect(component.courseId()).toBe(courseId); - }); - it('should get learning path', async () => { const getLearningPathIdSpy = jest.spyOn(learningPathApiService, 'getLearningPathForCurrentUser').mockResolvedValue(learningPath); fixture.detectChanges(); await fixture.whenStable(); - expect(getLearningPathIdSpy).toHaveBeenCalledWith(courseId); + expect(getLearningPathIdSpy).toHaveBeenCalledExactlyOnceWith(courseId); expect(component.learningPath()).toEqual(learningPath); }); @@ -116,7 +111,7 @@ describe('LearningPathStudentPageComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - expect(getLearningPathIdSpy).toHaveBeenCalledWith(courseId); + expect(getLearningPathIdSpy).toHaveBeenCalledExactlyOnceWith(courseId); expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); }); diff --git a/src/test/javascript/spec/service/learning-path/learning-path-api.service.spec.ts b/src/test/javascript/spec/service/learning-path/learning-path-api.service.spec.ts index d7f34259716e..265870ceae6d 100644 --- a/src/test/javascript/spec/service/learning-path/learning-path-api.service.spec.ts +++ b/src/test/javascript/spec/service/learning-path/learning-path-api.service.spec.ts @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { LearningObjectType } from 'app/entities/competency/learning-path.model'; import { provideHttpClient } from '@angular/common/http'; +import { SearchTermPageableSearch, SortingOrder } from 'app/shared/table/pageable-table'; describe('LearningPathApiService', () => { let httpClient: HttpTestingController; @@ -83,6 +84,13 @@ describe('LearningPathApiService', () => { await methodCall; }); + it('should get learning path instructor competency graph', async () => { + const methodCall = learningPathApiService.getLearningPathInstructorCompetencyGraph(courseId); + const response = httpClient.expectOne({ method: 'GET', url: `${baseUrl}/courses/${courseId}/learning-path/competency-instructor-graph` }); + response.flush({}); + await methodCall; + }); + it('should get learning path competencies', async () => { const methodCall = learningPathApiService.getLearningPathCompetencies(learningPathId); const response = httpClient.expectOne({ method: 'GET', url: `${baseUrl}/learning-path/${learningPathId}/competencies` }); @@ -100,4 +108,49 @@ describe('LearningPathApiService', () => { response.flush([]); await methodCall; }); + + it('should get learning paths configuration', async () => { + const methodCall = learningPathApiService.getLearningPathsConfiguration(courseId); + const response = httpClient.expectOne({ method: 'GET', url: `${baseUrl}/courses/${courseId}/learning-paths/configuration` }); + response.flush({}); + await methodCall; + }); + + it('should get learning path health status', async () => { + const methodCall = learningPathApiService.getLearningPathHealthStatus(courseId); + const response = httpClient.expectOne({ method: 'GET', url: `${baseUrl}/courses/${courseId}/learning-path-health` }); + response.flush({}); + await methodCall; + }); + + it('should put enable learning paths', async () => { + const methodCall = learningPathApiService.enableLearningPaths(courseId); + const response = httpClient.expectOne({ method: 'PUT', url: `${baseUrl}/courses/${courseId}/learning-paths/enable` }); + response.flush({}); + await methodCall; + }); + + it('should generate missing learning paths', async () => { + const methodCall = learningPathApiService.generateMissingLearningPaths(courseId); + const response = httpClient.expectOne({ method: 'PUT', url: `${baseUrl}/courses/${courseId}/learning-paths/generate-missing` }); + response.flush({}); + await methodCall; + }); + + it('should get learning path information', async () => { + const pageable = { + pageSize: 10, + page: 1, + searchTerm: 'search', + sortingOrder: SortingOrder.DESCENDING, + sortedColumn: 'column', + }; + const methodCall = learningPathApiService.getLearningPathInformation(courseId, pageable); + const response = httpClient.expectOne({ + method: 'GET', + url: `${baseUrl}/courses/${courseId}/learning-paths?pageSize=${pageable.pageSize}&page=${pageable.page}&sortingOrder=${pageable.sortingOrder}&searchTerm=${pageable.searchTerm}&sortedColumn=${pageable.sortedColumn}`, + }); + response.flush({}); + await methodCall; + }); });