From 258c094cf3a7c836fc83cb5d7baa419f3e1b507e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20St=C3=B6hr?= Date: Tue, 12 Nov 2024 13:00:15 +0100 Subject: [PATCH 1/9] Fix some bugs --- .../artemis/atlas/repository/CompetencyProgressRepository.java | 2 +- .../artemis/atlas/repository/CourseCompetencyRepository.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 2e397c06db36..22b740987786 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 @@ -96,7 +96,7 @@ SELECT COUNT(cp) 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) + SELECT COALESCE(GREATEST(0.0, LEAST(100.0, AVG(cp.progress * cp.confidence / com.masteryThreshold * 100))), 0.0) FROM CompetencyProgress cp LEFT JOIN cp.competency com LEFT JOIN com.course c diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java index fee3ca1e82f2..bba103f436d9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java @@ -126,7 +126,7 @@ CASE WHEN TYPE(e) = ProgrammingExercise THEN TRUE ELSE FALSE END, LEFT JOIN TeamScore tS ON tS.exercise = e AND :user MEMBER OF tS.team.students WHERE c.id = :competencyId AND e IS NOT NULL - GROUP BY e.id, e.maxPoints, e.difficulty, TYPE(e), sS.lastScore, tS.lastScore, sS.lastPoints, tS.lastPoints, sS.lastModifiedDate, tS.lastModifiedDate + GROUP BY e.id, e.maxPoints, e.difficulty, TYPE(e), el.weight, sS.lastScore, tS.lastScore, sS.lastPoints, tS.lastPoints, sS.lastModifiedDate, tS.lastModifiedDate """) Set findAllExerciseInfoByCompetencyIdAndUser(@Param("competencyId") long competencyId, @Param("user") User user); From bfadadd4474ea9d7b82bfdfcc3b2aa02ee932a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20St=C3=B6hr?= Date: Tue, 12 Nov 2024 13:49:49 +0100 Subject: [PATCH 2/9] Fix some more bugs --- .../dto/CompetencyStudentProgressDTO.java | 17 +++++++++++++++++ .../repository/CourseCompetencyRepository.java | 18 ++++++++++++++++++ .../competency/CourseCompetencyService.java | 7 +++---- .../atlas/web/CourseCompetencyResource.java | 7 +++---- .../services/course-competency-api.service.ts | 2 +- .../competency-node.component.ts | 1 + 6 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyStudentProgressDTO.java diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyStudentProgressDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyStudentProgressDTO.java new file mode 100644 index 000000000000..c0fc14f12db9 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyStudentProgressDTO.java @@ -0,0 +1,17 @@ +package de.tum.cit.aet.artemis.atlas.dto; + +import java.time.ZonedDateTime; + +import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyTaxonomy; +import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; + +public record CompetencyStudentProgressDTO(String title, String description, CompetencyTaxonomy taxonomy, ZonedDateTime softDueDate, boolean optional, String type, + long numberOfStudents, long numberOfMasteredStudents) { + + public static CompetencyStudentProgressDTO of(CourseCompetency courseCompetency) { + return new CompetencyStudentProgressDTO(courseCompetency.getTitle(), courseCompetency.getDescription(), courseCompetency.getTaxonomy(), courseCompetency.getSoftDueDate(), + courseCompetency.isOptional(), courseCompetency.getClass().getSimpleName(), courseCompetency.getUserProgress().size(), + courseCompetency.getUserProgress().stream().filter(CompetencyProgressService::isMastered).count()); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java index bba103f436d9..18ca5a1eef75 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java @@ -16,6 +16,7 @@ import de.tum.cit.aet.artemis.atlas.domain.LearningObject; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.dto.CompetencyStudentProgressDTO; import de.tum.cit.aet.artemis.atlas.dto.metrics.CompetencyExerciseMasteryCalculationDTO; import de.tum.cit.aet.artemis.atlas.dto.metrics.CompetencyLectureUnitMasteryCalculationDTO; import de.tum.cit.aet.artemis.core.domain.User; @@ -295,5 +296,22 @@ default CourseCompetency findByIdWithLectureUnitsAndExercisesElseThrow(long comp List findByCourseIdOrderById(long courseId); + @Query(""" + SELECT new de.tum.cit.aet.artemis.atlas.dto.CompetencyStudentProgressDTO( + c.title, + c.description, + c.taxonomy, + c.softDueDate, + c.optional, + CASE WHEN TYPE(c) = Competency THEN 'competency' ELSE 'prerequisite' END, + COUNT(up), + (SELECT COUNT(up) WHERE up.progress * up.confidence >= c.masteryThreshold) + ) + FROM CourseCompetency c + LEFT JOIN FETCH c.userProgress up + WHERE c.course.id = :courseId + """) + List findWithStudentProgressByCourseId(long courseId); + boolean existsByIdAndCourseId(long competencyId, long courseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java index 8e47f7443297..7ec7dc91a48b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java @@ -29,6 +29,7 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.StandardizedCompetency; import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyRelationDTO; +import de.tum.cit.aet.artemis.atlas.dto.CompetencyStudentProgressDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; import de.tum.cit.aet.artemis.atlas.dto.UpdateCourseCompetencyRelationDTO; import de.tum.cit.aet.artemis.atlas.repository.CompetencyLectureUnitLinkRepository; @@ -128,12 +129,10 @@ public CourseCompetency findCompetencyWithExercisesAndLectureUnitsAndProgressFor * As Spring Boot 3 doesn't support conditional JOIN FETCH statements, we have to retrieve the data manually. * * @param courseId The id of the course for which to fetch the competencies - * @param userId The id of the user for which to fetch the progress * @return The found competency */ - public List findCourseCompetenciesWithProgressForUserByCourseId(Long courseId, Long userId) { - List competencies = courseCompetencyRepository.findByCourseIdOrderById(courseId); - return findProgressForCompetenciesAndUser(competencies, userId); + public List findCourseCompetenciesWithStudentProgressByCourseId(Long courseId) { + return courseCompetencyRepository.findWithStudentProgressByCourseId(courseId); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java index 8e93c6c73090..af32b0f346b5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java @@ -33,6 +33,7 @@ import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyJolPairDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyRelationDTO; +import de.tum.cit.aet.artemis.atlas.dto.CompetencyStudentProgressDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; import de.tum.cit.aet.artemis.atlas.dto.UpdateCourseCompetencyRelationDTO; import de.tum.cit.aet.artemis.atlas.repository.CompetencyProgressRepository; @@ -43,7 +44,6 @@ import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyRelationService; import de.tum.cit.aet.artemis.atlas.service.competency.CourseCompetencyService; import de.tum.cit.aet.artemis.core.domain.Course; -import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.CourseCompetencyProgressDTO; import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; import de.tum.cit.aet.artemis.core.dto.pageablesearch.CompetencyPageableSearchDTO; @@ -167,10 +167,9 @@ public ResponseEntity getCourseCompetency(@PathVariable long c */ @GetMapping("courses/{courseId}/course-competencies") @EnforceAtLeastStudentInCourse - public ResponseEntity> getCourseCompetenciesWithProgress(@PathVariable long courseId) { + public ResponseEntity> getCourseCompetenciesWithProgress(@PathVariable long courseId) { log.debug("REST request to get competencies for course with id: {}", courseId); - User user = userRepository.getUserWithGroupsAndAuthorities(); - final var competencies = courseCompetencyService.findCourseCompetenciesWithProgressForUserByCourseId(courseId, user.getId()); + final var competencies = courseCompetencyService.findCourseCompetenciesWithStudentProgressByCourseId(courseId); return ResponseEntity.ok(competencies); } diff --git a/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts b/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts index 26dbb518ba68..5ed060119ba6 100644 --- a/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts +++ b/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts @@ -41,6 +41,6 @@ export class CourseCompetencyApiService extends BaseApiHttpService { } async getCourseCompetenciesByCourseId(courseId: number): Promise { - return await this.get(`${this.getPath(courseId)}`); + return await this.get(`${this.getPath(courseId)}`); } } 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 5365bb22387b..80fc6087cf46 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 @@ -37,6 +37,7 @@ export class CompetencyNodeComponent implements AfterViewInit { isGreen(): boolean { switch (this.valueType()) { case CompetencyGraphNodeValueType.MASTERY_PROGRESS: + case CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS: return this.value() >= 100; default: return false; From 4aacce8950a8e79bb25abbd75b554c35552ad055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20St=C3=B6hr?= Date: Wed, 13 Nov 2024 18:35:09 +0100 Subject: [PATCH 3/9] Fix compilation and add id --- .../aet/artemis/atlas/dto/CompetencyStudentProgressDTO.java | 6 +++--- .../atlas/repository/CourseCompetencyRepository.java | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyStudentProgressDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyStudentProgressDTO.java index c0fc14f12db9..932fe9873af1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyStudentProgressDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyStudentProgressDTO.java @@ -6,12 +6,12 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; -public record CompetencyStudentProgressDTO(String title, String description, CompetencyTaxonomy taxonomy, ZonedDateTime softDueDate, boolean optional, String type, +public record CompetencyStudentProgressDTO(long id, String title, String description, CompetencyTaxonomy taxonomy, ZonedDateTime softDueDate, boolean optional, String type, long numberOfStudents, long numberOfMasteredStudents) { public static CompetencyStudentProgressDTO of(CourseCompetency courseCompetency) { - return new CompetencyStudentProgressDTO(courseCompetency.getTitle(), courseCompetency.getDescription(), courseCompetency.getTaxonomy(), courseCompetency.getSoftDueDate(), - courseCompetency.isOptional(), courseCompetency.getClass().getSimpleName(), courseCompetency.getUserProgress().size(), + return new CompetencyStudentProgressDTO(courseCompetency.getId(), courseCompetency.getTitle(), courseCompetency.getDescription(), courseCompetency.getTaxonomy(), + courseCompetency.getSoftDueDate(), courseCompetency.isOptional(), courseCompetency.getClass().getSimpleName(), courseCompetency.getUserProgress().size(), courseCompetency.getUserProgress().stream().filter(CompetencyProgressService::isMastered).count()); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java index 18ca5a1eef75..6f37386263a2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java @@ -298,6 +298,7 @@ default CourseCompetency findByIdWithLectureUnitsAndExercisesElseThrow(long comp @Query(""" SELECT new de.tum.cit.aet.artemis.atlas.dto.CompetencyStudentProgressDTO( + c.id, c.title, c.description, c.taxonomy, @@ -308,7 +309,7 @@ CASE WHEN TYPE(c) = Competency THEN 'competency' ELSE 'prerequisite' END, (SELECT COUNT(up) WHERE up.progress * up.confidence >= c.masteryThreshold) ) FROM CourseCompetency c - LEFT JOIN FETCH c.userProgress up + LEFT JOIN c.userProgress up WHERE c.course.id = :courseId """) List findWithStudentProgressByCourseId(long courseId); From c49f75a39a4ae4e0629d9d48d7967303db2f0407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20St=C3=B6hr?= Date: Wed, 13 Nov 2024 19:37:45 +0100 Subject: [PATCH 4/9] Fix Server endpoints --- .../CourseCompetencyRepository.java | 3 ++- .../competency/CourseCompetencyService.java | 14 ++++++++++++++ .../atlas/web/CourseCompetencyResource.java | 19 ++++++++++++++++++- .../services/course-competency-api.service.ts | 6 +++++- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java index 6f37386263a2..85db6cacd199 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java @@ -306,11 +306,12 @@ default CourseCompetency findByIdWithLectureUnitsAndExercisesElseThrow(long comp c.optional, CASE WHEN TYPE(c) = Competency THEN 'competency' ELSE 'prerequisite' END, COUNT(up), - (SELECT COUNT(up) WHERE up.progress * up.confidence >= c.masteryThreshold) + COUNT(CASE WHEN up.progress * up.confidence >= c.masteryThreshold THEN 1 ELSE 0 END) ) FROM CourseCompetency c LEFT JOIN c.userProgress up WHERE c.course.id = :courseId + GROUP BY c """) List findWithStudentProgressByCourseId(long courseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java index 7ec7dc91a48b..bdd555c8c11e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java @@ -123,6 +123,20 @@ public CourseCompetency findCompetencyWithExercisesAndLectureUnitsAndProgressFor return findProgressAndLectureUnitCompletionsForUser(competency, userId); } + /** + * Finds competencies within a course and fetch progress for the provided user. + *

+ * As Spring Boot 3 doesn't support conditional JOIN FETCH statements, we have to retrieve the data manually. + * + * @param courseId The id of the course for which to fetch the competencies + * @param userId The id of the user for which to fetch the progress + * @return The found competency + */ + public List findCourseCompetenciesWithProgressForUserByCourseId(Long courseId, Long userId) { + List competencies = courseCompetencyRepository.findByCourseIdOrderById(courseId); + return findProgressForCompetenciesAndUser(competencies, userId); + } + /** * Finds competencies within a course and fetch progress for the provided user. *

diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java index af32b0f346b5..8d93f272f854 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java @@ -44,6 +44,7 @@ import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyRelationService; import de.tum.cit.aet.artemis.atlas.service.competency.CourseCompetencyService; import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.CourseCompetencyProgressDTO; import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; import de.tum.cit.aet.artemis.core.dto.pageablesearch.CompetencyPageableSearchDTO; @@ -56,6 +57,7 @@ import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastEditorInCourse; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastStudentInCourse; +import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastTutorInCourse; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.service.feature.Feature; import de.tum.cit.aet.artemis.core.service.feature.FeatureToggle; @@ -167,7 +169,22 @@ public ResponseEntity getCourseCompetency(@PathVariable long c */ @GetMapping("courses/{courseId}/course-competencies") @EnforceAtLeastStudentInCourse - public ResponseEntity> getCourseCompetenciesWithProgress(@PathVariable long courseId) { + public ResponseEntity> getCourseCompetenciesWithOwnProgress(@PathVariable long courseId) { + log.debug("REST request to get competencies for course with id: {}", courseId); + User user = userRepository.getUserWithGroupsAndAuthorities(); + final var competencies = courseCompetencyService.findCourseCompetenciesWithProgressForUserByCourseId(courseId, user.getId()); + return ResponseEntity.ok(competencies); + } + + /** + * GET courses/:courseId/course-competencies : gets all the course competencies of a course + * + * @param courseId the id of the course for which the competencies should be fetched + * @return the ResponseEntity with status 200 (OK) and with body the found competencies + */ + @GetMapping("courses/{courseId}/course-competencies-student-progress") + @EnforceAtLeastTutorInCourse + public ResponseEntity> getCourseCompetenciesWithStudentProgress(@PathVariable long courseId) { log.debug("REST request to get competencies for course with id: {}", courseId); final var competencies = courseCompetencyService.findCourseCompetenciesWithStudentProgressByCourseId(courseId); return ResponseEntity.ok(competencies); diff --git a/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts b/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts index 5ed060119ba6..37c70384056d 100644 --- a/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts +++ b/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts @@ -40,7 +40,11 @@ export class CourseCompetencyApiService extends BaseApiHttpService { return await this.get(`${this.getPath(courseId)}/relations`); } - async getCourseCompetenciesByCourseId(courseId: number): Promise { + async getCourseCompetenciesWithOwnProgressByCourseId(courseId: number): Promise { return await this.get(`${this.getPath(courseId)}`); } + + async getCourseCompetenciesWithStudentProgressByCourseId(courseId: number): Promise { + return await this.get(`${this.getPath(courseId)}-student-progress`); + } } From ed94d0282bf72b52ef8b601ff3c43662f5b2cc5d Mon Sep 17 00:00:00 2001 From: Johannes Wiest Date: Thu, 14 Nov 2024 10:08:56 +0100 Subject: [PATCH 5/9] add new competencies table and tests --- .../competency-management.component.html | 28 +- .../competency-management.component.ts | 51 +++- ...mpetencies-management-table.component.html | 120 ++++++++ ...mpetencies-management-table.component.scss | 13 + ...competencies-management-table.component.ts | 145 +++++++++ .../services/course-competency-api.service.ts | 10 +- .../webapp/app/entities/competency.model.ts | 12 + src/main/webapp/i18n/de/competency.json | 2 +- src/main/webapp/i18n/en/competency.json | 2 +- .../competency-management.component.spec.ts | 49 ++- ...tencies-management-table.component.spec.ts | 287 ++++++++++++++++++ .../mocks/service/mock-alert.service.ts | 1 + .../course-competency-api.service.spec.ts | 6 +- 13 files changed, 670 insertions(+), 56 deletions(-) create mode 100644 src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.html create mode 100644 src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.scss create mode 100644 src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.ts create mode 100644 src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html index ae2211f1c881..829f075b669b 100644 --- a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html +++ b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html @@ -1,7 +1,7 @@

-

+

@if (irisCompetencyGenerationEnabled()) { - + } - - @@ -31,20 +31,20 @@

} - -
diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts index ddad887fc1a2..ad25c381ece9 100644 --- a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts +++ b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts @@ -1,7 +1,7 @@ -import { Component, OnInit, computed, effect, inject, signal, untracked } from '@angular/core'; +import { Component, computed, effect, inject, signal, untracked } from '@angular/core'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { AlertService } from 'app/core/util/alert.service'; -import { CompetencyWithTailRelationDTO, CourseCompetency, CourseCompetencyType, getIcon } from 'app/entities/competency.model'; +import { CompetencyWithTailRelationDTO, CourseCompetency, CourseCompetencyStudentProgressDTO, CourseCompetencyType, getIcon } from 'app/entities/competency.model'; import { firstValueFrom, map } from 'rxjs'; import { faCircleQuestion, faEdit, faFileImport, faPencilAlt, faPlus, faRobot, faTrash } from '@fortawesome/free-solid-svg-icons'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @@ -22,14 +22,15 @@ import { ArtemisSharedComponentModule } from 'app/shared/components/shared-compo import { CourseCompetenciesRelationModalComponent } from 'app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component'; import { CourseCompetencyExplanationModalComponent } from 'app/course/competencies/components/course-competency-explanation-modal/course-competency-explanation-modal.component'; import { toSignal } from '@angular/core/rxjs-interop'; +import { CourseCompetenciesManagementTableComponent } from 'app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component'; @Component({ selector: 'jhi-competency-management', templateUrl: './competency-management.component.html', standalone: true, - imports: [CompetencyManagementTableComponent, TranslateDirective, FontAwesomeModule, RouterModule, ArtemisSharedComponentModule], + imports: [CompetencyManagementTableComponent, TranslateDirective, FontAwesomeModule, RouterModule, ArtemisSharedComponentModule, CourseCompetenciesManagementTableComponent], }) -export class CompetencyManagementComponent implements OnInit { +export class CompetencyManagementComponent { protected readonly faEdit = faEdit; protected readonly faPlus = faPlus; protected readonly faFileImport = faFileImport; @@ -53,7 +54,7 @@ export class CompetencyManagementComponent implements OnInit { readonly courseId = toSignal(this.activatedRoute.parent!.params.pipe(map((params) => Number(params.courseId))), { requireSync: true }); readonly isLoading = signal(false); - readonly courseCompetencies = signal([]); + readonly courseCompetencies = signal([]); competencies = computed(() => this.courseCompetencies().filter((cc) => cc.type === CourseCompetencyType.COMPETENCY)); prerequisites = computed(() => this.courseCompetencies().filter((cc) => cc.type === CourseCompetencyType.PREREQUISITE)); @@ -77,20 +78,19 @@ export class CompetencyManagementComponent implements OnInit { } }); }); - } - - ngOnInit(): void { - const lastVisit = sessionStorage.getItem('lastTimeVisitedCourseCompetencyExplanation'); - if (!lastVisit) { - this.openCourseCompetencyExplanation(); - } - sessionStorage.setItem('lastTimeVisitedCourseCompetencyExplanation', Date.now().toString()); + effect(() => { + const lastVisit = localStorage.getItem('lastTimeVisitedCourseCompetencyExplanation'); + if (!lastVisit) { + this.openCourseCompetencyExplanation(); + } + localStorage.setItem('lastTimeVisitedCourseCompetencyExplanation', Date.now().toString()); + }); } private async loadIrisEnabled() { try { const combinedCourseSettings = await firstValueFrom(this.irisSettingsService.getCombinedCourseSettings(this.courseId())); - this.irisCompetencyGenerationEnabled.set(combinedCourseSettings?.irisCompetencyGenerationSettings?.enabled ?? false); + this.irisCompetencyGenerationEnabled.set(!!combinedCourseSettings?.irisCompetencyGenerationSettings?.enabled); } catch (error) { this.alertService.error(error); } @@ -99,7 +99,7 @@ export class CompetencyManagementComponent implements OnInit { private async loadCourseCompetencies(courseId: number) { try { this.isLoading.set(true); - const courseCompetencies = await this.courseCompetencyApiService.getCourseCompetenciesByCourseId(courseId); + const courseCompetencies = await this.courseCompetencyApiService.getCourseCompetenciesWithStudentProgressByCourseId(courseId); this.courseCompetencies.set(courseCompetencies); } catch (error) { this.alertService.error(error); @@ -154,7 +154,22 @@ export class CompetencyManagementComponent implements OnInit { * @private */ updateDataAfterImportAll(res: Array) { - const importedCourseCompetencies = res.map((dto) => dto.competency!); + const importedCourseCompetencies = res + .map((dto) => dto.competency!) + .map( + (courseCompetency) => + { + id: courseCompetency.id, + title: courseCompetency.title, + description: courseCompetency.description, + numberOfMasteredStudents: 0, + numberOfStudents: 0, + optional: courseCompetency.optional, + softDueDate: courseCompetency.softDueDate, + taxonomy: courseCompetency.taxonomy, + type: courseCompetency.type, + }, + ); this.courseCompetencies.update((courseCompetencies) => courseCompetencies.concat(importedCourseCompetencies)); } @@ -162,6 +177,10 @@ export class CompetencyManagementComponent implements OnInit { this.courseCompetencies.update((courseCompetencies) => courseCompetencies.filter((cc) => cc.id !== competencyId)); } + onCourseCompetenciesImport(importedCompetencies: CourseCompetencyStudentProgressDTO[]) { + this.courseCompetencies.update((courseCompetencies) => [...courseCompetencies, ...importedCompetencies]); + } + openCourseCompetencyExplanation(): void { this.modalService.open(CourseCompetencyExplanationModalComponent, { size: 'xl', diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.html b/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.html new file mode 100644 index 000000000000..a10bec0d1005 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.html @@ -0,0 +1,120 @@ +
+
+
+ @if (irisCompetencyGenerationEnabled()) { + + + {{ 'artemisApp.competency.manageCompetencies.generateButton' | artemisTranslate }} + + } +
+ +
+ + + + + + @if (standardizedCompetenciesEnabled()) { + + + + + } +
+
+ + + + +
+
+
+
+ + + + + + + + + + + + + + @for (courseCompetency of courseCompetencies(); track courseCompetency.id) { + + + + + + + + + + } @empty { + + + + } + +
# + + + + + + + + + +
{{ courseCompetency.id }} +
+ @let taxonomy = courseCompetency.taxonomy ?? 'none'; + + + {{ courseCompetency.title }} + +
+
{{ courseCompetency.softDueDate | artemisDate }} + {{ courseCompetency.numberOfStudents == 0 ? 0 : ((courseCompetency.numberOfMasteredStudents / courseCompetency.numberOfStudents) * 100 | number: '1.0-0') }} + % + + + +
+ + + + + +
+
+
+ +
+
+
diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.scss b/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.scss new file mode 100644 index 000000000000..d1b3365545d6 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.scss @@ -0,0 +1,13 @@ +.course-competency-table-container { + height: 280px; + overflow-y: scroll; + + table { + thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--bs-card-bg); + } + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.ts b/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.ts new file mode 100644 index 000000000000..abe5613c4870 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.ts @@ -0,0 +1,145 @@ +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal, untracked } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faEdit, faFileImport, faPlus, faRobot, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { CourseCompetency, CourseCompetencyStudentProgressDTO, CourseCompetencyType, getIcon } from 'app/entities/competency.model'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AlertService } from 'app/core/util/alert.service'; +import { outputToObservable, toSignal } from '@angular/core/rxjs-interop'; +import { IrisCourseSettings } from 'app/entities/iris/settings/iris-settings.model'; +import { PROFILE_IRIS } from 'app/app.constants'; +import { lastValueFrom } from 'rxjs'; +import { onError } from 'app/shared/util/global.utils'; +import { ImportAllCompetenciesComponent, ImportAllFromCourseResult } from 'app/course/competencies/competency-management/import-all-competencies.component'; +import { CompetencyService } from 'app/course/competencies/competency.service'; +import { PrerequisiteService } from 'app/course/competencies/prerequisite.service'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'jhi-course-competencies-management-table', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, FontAwesomeModule, TranslateDirective, ArtemisSharedModule, ArtemisMarkdownModule, CommonModule], + templateUrl: './course-competencies-management-table.component.html', + styleUrl: './course-competencies-management-table.component.scss', +}) +export class CourseCompetenciesManagementTableComponent { + protected readonly getIcon = getIcon; + + protected readonly faRobot = faRobot; + protected readonly faTrash = faTrash; + protected readonly faEdit = faEdit; + protected readonly faPlus = faPlus; + protected readonly faFileImport = faFileImport; + + private readonly profileService = inject(ProfileService); + private readonly irisSettingsService = inject(IrisSettingsService); + private readonly modalService = inject(NgbModal); + private readonly alertService = inject(AlertService); + private readonly competencyService = inject(CompetencyService); + private readonly prerequisiteService = inject(PrerequisiteService); + private service: CompetencyService | PrerequisiteService; + + readonly courseId = input.required(); + readonly courseCompetencies = input.required(); + readonly courseCompetencyType = input.required(); + readonly standardizedCompetenciesEnabled = input.required(); + + readonly onCourseCompetencyDeletion = output(); + readonly onCourseCompetenciesImport = output(); + + readonly dialogErrorSource = output(); + readonly dialogError = outputToObservable(this.dialogErrorSource); + + private readonly profileInfo = toSignal(this.profileService.getProfileInfo()); + private readonly irisCombinedSettings = signal(undefined); + private readonly irisEnabled = computed(() => this.profileInfo()?.activeProfiles.includes(PROFILE_IRIS) ?? false); + readonly irisCompetencyGenerationEnabled = computed(() => { + return this.irisEnabled() && (this.irisCombinedSettings()?.irisCompetencyGenerationSettings?.enabled ?? false); + }); + + constructor() { + effect(() => { + const courseId = this.courseId(); + untracked(() => this.loadIrisSettings(courseId)); + }); + effect(() => { + if (this.courseCompetencyType() === CourseCompetencyType.COMPETENCY) { + this.service = this.competencyService; + } else { + this.service = this.prerequisiteService; + } + }); + } + + private async loadIrisSettings(courseId: number): Promise { + if (this.irisEnabled()) { + try { + const irisCombinedSettings = await lastValueFrom(this.irisSettingsService.getCombinedCourseSettings(courseId)); + this.irisCombinedSettings.set(irisCombinedSettings); + } catch (error) { + onError(this.alertService, error); + } + } + } + + protected async deleteCourseCompetency(courseCompetencyId: number): Promise { + try { + await lastValueFrom(this.service.delete(courseCompetencyId, this.courseId())); + this.dialogErrorSource.emit(''); + this.onCourseCompetencyDeletion.emit(courseCompetencyId); + } catch (error) { + this.dialogErrorSource.emit(error.message); + } + } + + protected async openImportAllModal(courseCompetencyType: string): Promise { + const modal = this.modalService.open(ImportAllCompetenciesComponent, { size: 'lg', backdrop: 'static' }); + //unary operator is necessary as otherwise courseId is seen as a string and will not match. + modal.componentInstance.disabledIds = [+this.courseId()]; + modal.componentInstance.competencyType = courseCompetencyType; + const result = await modal.result; + await this.importAllCourseCompetencies(result as ImportAllFromCourseResult); + } + + private async importAllCourseCompetencies(result: ImportAllFromCourseResult): Promise { + const courseTitle = result.courseForImportDTO.title ?? ''; + try { + const response = await lastValueFrom(this.service.importAll(this.courseId(), result.courseForImportDTO.id!, result.importRelations)); + const importedCompetenciesWithTailRelation = response.body ?? []; + const importedCompetencies = importedCompetenciesWithTailRelation + .map((dto) => dto.competency) + .filter((element): element is CourseCompetency => !!element) + .map( + (courseCompetency) => + { + id: courseCompetency.id, + title: courseCompetency.title, + description: courseCompetency.description, + numberOfMasteredStudents: 0, + numberOfStudents: 0, + optional: courseCompetency.optional, + softDueDate: courseCompetency.softDueDate, + taxonomy: courseCompetency.taxonomy, + type: courseCompetency.type, + }, + ); + if (importedCompetencies.length > 0) { + this.alertService.success(`artemisApp.${this.courseCompetencyType()}.importAll.success`, { + noOfCompetencies: importedCompetencies.length, + courseTitle: courseTitle, + }); + this.onCourseCompetenciesImport.emit(importedCompetencies); + } else { + this.alertService.warning(`artemisApp.${this.courseCompetencyType()}.importAll.warning`, { courseTitle: courseTitle }); + } + } catch (error) { + this.alertService.error(error); + } + } +} diff --git a/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts b/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts index 37c70384056d..38c990794f5f 100644 --- a/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts +++ b/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts @@ -3,8 +3,8 @@ import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api- import { CompetencyRelationDTO, CompetencyWithTailRelationDTO, - CourseCompetency, CourseCompetencyImportOptionsDTO, + CourseCompetencyStudentProgressDTO, UpdateCourseCompetencyRelationDTO, } from 'app/entities/competency.model'; @@ -40,11 +40,7 @@ export class CourseCompetencyApiService extends BaseApiHttpService { return await this.get(`${this.getPath(courseId)}/relations`); } - async getCourseCompetenciesWithOwnProgressByCourseId(courseId: number): Promise { - return await this.get(`${this.getPath(courseId)}`); - } - - async getCourseCompetenciesWithStudentProgressByCourseId(courseId: number): Promise { - return await this.get(`${this.getPath(courseId)}-student-progress`); + async getCourseCompetenciesWithStudentProgressByCourseId(courseId: number): Promise { + return await this.get(`${this.getPath(courseId)}-student-progress`); } } diff --git a/src/main/webapp/app/entities/competency.model.ts b/src/main/webapp/app/entities/competency.model.ts index a1e01a1afb71..2e96a576bac5 100644 --- a/src/main/webapp/app/entities/competency.model.ts +++ b/src/main/webapp/app/entities/competency.model.ts @@ -63,6 +63,18 @@ export interface UpdateCourseCompetencyRelationDTO { newRelationType: CompetencyRelationType; } +export interface CourseCompetencyStudentProgressDTO { + id: number; + title: string; + description?: string; + taxonomy?: CompetencyTaxonomy; + softDueDate?: dayjs.Dayjs; + optional: boolean; + numberOfStudents: number; + numberOfMasteredStudents: number; + type: CourseCompetencyType; +} + export abstract class CourseCompetency extends BaseCompetency { softDueDate?: dayjs.Dayjs; masteryThreshold?: number; diff --git a/src/main/webapp/i18n/de/competency.json b/src/main/webapp/i18n/de/competency.json index f436d0471336..c4280ff503ae 100644 --- a/src/main/webapp/i18n/de/competency.json +++ b/src/main/webapp/i18n/de/competency.json @@ -5,7 +5,7 @@ "deleted": "Kompetenz gelöscht", "updated": "Kompetenz bearbeitet", "competencyButton": "Kompetenzen", - "title": "Titel", + "title": "Kompetenzen", "description": "Beschreibung", "taxonomy": "Taxonomie", "softDueDate": "Empfohlen bis", diff --git a/src/main/webapp/i18n/en/competency.json b/src/main/webapp/i18n/en/competency.json index f43c0d017273..b68d388b1446 100644 --- a/src/main/webapp/i18n/en/competency.json +++ b/src/main/webapp/i18n/en/competency.json @@ -5,7 +5,7 @@ "deleted": "Competency deleted", "updated": "Competency updated", "competencyButton": "Competencies", - "title": "Title", + "title": "Competencies", "description": "Description", "taxonomy": "Taxonomy", "softDueDate": "Recommended until", diff --git a/src/test/javascript/spec/component/competencies/competency-management/competency-management.component.spec.ts b/src/test/javascript/spec/component/competencies/competency-management/competency-management.component.spec.ts index 86b505b1f78e..3f056aaf5742 100644 --- a/src/test/javascript/spec/component/competencies/competency-management/competency-management.component.spec.ts +++ b/src/test/javascript/spec/component/competencies/competency-management/competency-management.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; -import { Competency, CompetencyWithTailRelationDTO, CourseCompetencyProgress, CourseCompetencyType } from 'app/entities/competency.model'; +import { CompetencyWithTailRelationDTO, CourseCompetencyStudentProgressDTO, CourseCompetencyType } from 'app/entities/competency.model'; import { CompetencyManagementComponent } from 'app/course/competencies/competency-management/competency-management.component'; import { ActivatedRoute, provideRouter } from '@angular/router'; import { DeleteButtonDirective } from 'app/shared/delete-dialog/delete-button.directive'; @@ -23,7 +23,6 @@ import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.serv import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; import { IrisCourseSettings } from 'app/entities/iris/settings/iris-settings.model'; import { PROFILE_IRIS } from 'app/app.constants'; -import { Prerequisite } from 'app/entities/prerequisite.model'; import { CompetencyManagementTableComponent } from 'app/course/competencies/competency-management/competency-management-table.component'; import { CourseCompetencyApiService } from 'app/course/competencies/services/course-competency-api.service'; import { @@ -32,6 +31,7 @@ import { } from 'app/course/competencies/components/import-all-course-competencies-modal/import-all-course-competencies-modal.component'; import { MockProfileService } from '../../../helpers/mocks/service/mock-profile.service'; import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { HttpErrorResponse } from '@angular/common/http'; describe('CompetencyManagementComponent', () => { let fixture: ComponentFixture; @@ -83,6 +83,13 @@ describe('CompetencyManagementComponent', () => { }, }, }, + { + provide: CourseCompetencyApiService, + useValue: { + getCourseCompetenciesWithStudentProgressByCourseId: jest.fn(), + importAllByCourseId: jest.fn(), + }, + }, ], schemas: [], }).compileComponents(); @@ -92,22 +99,36 @@ describe('CompetencyManagementComponent', () => { profileService = TestBed.inject(ProfileService); alertService = TestBed.inject(AlertService); - const competency: Competency = new Competency(); - competency.id = 1; - competency.description = 'test'; - const courseCompetencyProgress = new CourseCompetencyProgress(); - courseCompetencyProgress.competencyId = 1; - courseCompetencyProgress.numberOfStudents = 8; - courseCompetencyProgress.numberOfMasteredStudents = 5; - courseCompetencyProgress.averageStudentScore = 90; + const competency = { + id: 1, + description: 'test', + type: CourseCompetencyType.COMPETENCY, + numberOfStudents: 8, + numberOfMasteredStudents: 5, + optional: false, + title: 'Competency 1', + }; - getAllForCourseSpy = jest.spyOn(courseCompetencyApiService, 'getCourseCompetenciesByCourseId').mockResolvedValue([ + getAllForCourseSpy = jest.spyOn(courseCompetencyApiService, 'getCourseCompetenciesWithStudentProgressByCourseId').mockResolvedValue([ competency, - { id: 5, type: CourseCompetencyType.COMPETENCY } as Competency, + { + id: 5, + type: CourseCompetencyType.COMPETENCY, + title: 'Competency 2', + description: 'test', + optional: false, + numberOfMasteredStudents: 0, + numberOfStudents: 0, + } as CourseCompetencyStudentProgressDTO, { id: 3, type: CourseCompetencyType.PREREQUISITE, - } as Prerequisite, + title: 'Prerequisite 1', + description: 'test', + optional: false, + numberOfMasteredStudents: 0, + numberOfStudents: 0, + } as CourseCompetencyStudentProgressDTO, ]); const profileInfoResponse = { @@ -168,7 +189,7 @@ describe('CompetencyManagementComponent', () => { it('should show alert when loading iris settings fails', async () => { const errorSpy = jest.spyOn(alertService, 'error'); - getIrisSettingsSpy.mockRejectedValueOnce({}); + getIrisSettingsSpy.mockReturnValue(new HttpErrorResponse({ status: 500 })); fixture.detectChanges(); await fixture.whenStable(); diff --git a/src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts b/src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts new file mode 100644 index 000000000000..bd1aa490c072 --- /dev/null +++ b/src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts @@ -0,0 +1,287 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { CourseCompetenciesManagementTableComponent } from 'app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component'; +import { CompetencyWithTailRelationDTO, CourseCompetency, CourseCompetencyStudentProgressDTO, CourseCompetencyType } from 'app/entities/competency.model'; +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 { MockLocalStorageService } from '../../../helpers/mocks/service/mock-local-storage.service'; +import { LocalStorageService } from 'ngx-webstorage'; +import { Routes, provideRouter } from '@angular/router'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { of, throwError } from 'rxjs'; +import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; +import { PROFILE_IRIS } from 'app/app.constants'; +import { IrisSettings } from 'app/entities/iris/settings/iris-settings.model'; +import { CompetencyService } from '../../../../../../main/webapp/app/course/competencies/competency.service'; +import { PrerequisiteService } from '../../../../../../main/webapp/app/course/competencies/prerequisite.service'; + +describe('CourseCompetenciesManagementTable', () => { + let component: CourseCompetenciesManagementTableComponent; + let fixture: ComponentFixture; + let profileService: ProfileService; + let irisSettingsService: IrisSettingsService; + let alertService: AlertService; + let competencyService: CompetencyService; + + const courseId = 1; + const courseCompetencies: CourseCompetencyStudentProgressDTO[] = [ + { + id: 1, + type: CourseCompetencyType.COMPETENCY, + title: 'Competency 1', + description: 'Description 1', + numberOfMasteredStudents: 1, + numberOfStudents: 2, + optional: false, + }, + { + id: 2, + type: CourseCompetencyType.PREREQUISITE, + title: 'Prerequisite 1', + description: 'Description 2', + numberOfMasteredStudents: 3, + numberOfStudents: 4, + optional: false, + }, + ]; + const courseCompetencyType = CourseCompetencyType.COMPETENCY; + const standardizedCompetenciesEnabled = false; + + const routes: Routes = []; + + const modalResult = { courseForImportDTO: { id: 2, title: 'Import Course Title' }, importRelations: false }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CourseCompetenciesManagementTableComponent], + providers: [ + provideRouter(routes), + provideHttpClient(), + provideHttpClientTesting(), + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { + provide: LocalStorageService, + useClass: MockLocalStorageService, + }, + { + provide: AlertService, + useClass: MockAlertService, + }, + { + provide: IrisSettingsService, + useValue: { + getCombinedCourseSettings: jest.fn(() => of({ irisCompetencyGenerationSettings: { enabled: true } })), + }, + }, + { + provide: ProfileService, + useValue: { + getProfileInfo: jest.fn(() => of({ activeProfiles: [PROFILE_IRIS] })), + }, + }, + { + provide: NgbModal, + useValue: { + open: jest.fn(), + }, + }, + { + provide: CompetencyService, + useValue: { + importAll: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: PrerequisiteService, + useValue: { + importAll: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }).compileComponents(); + + profileService = TestBed.inject(ProfileService); + irisSettingsService = TestBed.inject(IrisSettingsService); + alertService = TestBed.inject(AlertService); + competencyService = TestBed.inject(CompetencyService); + + fixture = TestBed.createComponent(CourseCompetenciesManagementTableComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput('courseId', courseId); + fixture.componentRef.setInput('courseCompetencies', courseCompetencies); + fixture.componentRef.setInput('courseCompetencyType', courseCompetencyType); + fixture.componentRef.setInput('standardizedCompetenciesEnabled', standardizedCompetenciesEnabled); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should load profileInfo', async () => { + const getProfileInfoSpy = jest.spyOn(profileService, 'getProfileInfo'); + const getCourseSettingsSpy = jest.spyOn(irisSettingsService, 'getCombinedCourseSettings'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(getProfileInfoSpy).toHaveBeenCalledOnce(); + expect(getCourseSettingsSpy).toHaveBeenCalledExactlyOnceWith(courseId); + expect(component.irisCompetencyGenerationEnabled()).toBeTrue(); + }); + + it('should show error on load profileInfo error', async () => { + jest.spyOn(irisSettingsService, 'getCombinedCourseSettings').mockReturnValueOnce(throwError(() => new Error('Error'))); + const errorSpy = jest.spyOn(alertService, 'addAlert'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(errorSpy).toHaveBeenCalledOnce(); + }); + + it('should show data in table', () => { + fixture.detectChanges(); + + const tableRows = fixture.nativeElement.querySelectorAll('tr'); + expect(tableRows).toHaveLength(courseCompetencies.length + 1); + }); + + it('should delete course competency', async () => { + fixture.detectChanges(); + const deleteCourseCompetencySpy = jest.spyOn(competencyService, 'delete').mockReturnValue(of(new HttpResponse({ status: 200 }))); + const dialogErrorSourceEmitSpy = jest.spyOn(component.dialogErrorSource, 'emit'); + + await component['deleteCourseCompetency'](1); + + expect(deleteCourseCompetencySpy).toHaveBeenCalledExactlyOnceWith(1, courseId); + expect(dialogErrorSourceEmitSpy).toHaveBeenCalledExactlyOnceWith(''); + }); + + it('should emit error on delete course competency error', async () => { + fixture.detectChanges(); + jest.spyOn(competencyService, 'delete').mockReturnValue(of(new HttpResponse({ status: 500 }))); + const dialogErrorSourceEmitSpy = jest.spyOn(component.dialogErrorSource, 'emit'); + + await component['deleteCourseCompetency'](1); + + expect(dialogErrorSourceEmitSpy).toHaveBeenCalledOnce(); + }); + + it('should import competencies via modal', async () => { + fixture.detectChanges(); + const openSpy = jest.spyOn(fixture.componentRef.injector.get(NgbModal), 'open').mockReturnValue({ + componentInstance: { + disabledIds: [], + competencyType: '', + }, + result: modalResult, + } as any); + const importedCompetencies: CompetencyWithTailRelationDTO[] = [ + { + competency: { + id: 3, + type: CourseCompetencyType.COMPETENCY, + title: 'Imported competency', + }, + }, + ]; + const importAllCompetenciesSpy = jest.spyOn(competencyService, 'importAll').mockReturnValue(of(new HttpResponse({ status: 200, body: importedCompetencies }))); + const onCourseCompetenciesImportSpy = jest.spyOn(component.onCourseCompetenciesImport, 'emit'); + const successSpy = jest.spyOn(alertService, 'success'); + + const importAllButton = fixture.nativeElement.querySelector('#importAllCompetenciesButton'); + importAllButton.click(); + + expect(openSpy).toHaveBeenCalledOnce(); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(importAllCompetenciesSpy).toHaveBeenCalledExactlyOnceWith(courseId, modalResult.courseForImportDTO.id, modalResult.importRelations); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(successSpy).toHaveBeenCalledOnce(); + const importedMappedCompetencies = importedCompetencies + .map((dto) => dto.competency) + .filter((element): element is CourseCompetency => !!element) + .map( + (courseCompetency) => + { + id: courseCompetency.id, + title: courseCompetency.title, + description: courseCompetency.description, + numberOfMasteredStudents: 0, + numberOfStudents: 0, + optional: courseCompetency.optional, + softDueDate: courseCompetency.softDueDate, + taxonomy: courseCompetency.taxonomy, + type: courseCompetency.type, + }, + ); + expect(onCourseCompetenciesImportSpy).toHaveBeenCalledExactlyOnceWith(importedMappedCompetencies); + }); + + it('should show warning when no imported competencies exist', async () => { + fixture.detectChanges(); + jest.spyOn(fixture.componentRef.injector.get(NgbModal), 'open').mockReturnValue({ + componentInstance: { + disabledIds: [], + competencyType: '', + }, + result: modalResult, + } as any); + const importedCompetencies: CompetencyWithTailRelationDTO[] = []; + jest.spyOn(competencyService, 'importAll').mockReturnValue( + of( + new HttpResponse({ + status: 200, + body: importedCompetencies, + }), + ), + ); + const warningSpy = jest.spyOn(alertService, 'warning'); + + const importAllButton = fixture.nativeElement.querySelector('#importAllCompetenciesButton'); + importAllButton.click(); + + await fixture.whenStable(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(warningSpy).toHaveBeenCalledOnce(); + }); + + it('should show error on import competencies error', async () => { + jest.spyOn(fixture.componentRef.injector.get(NgbModal), 'open').mockReturnValue({ + componentInstance: { + disabledIds: [], + competencyType: '', + }, + result: modalResult, + } as any); + jest.spyOn(competencyService, 'importAll').mockReturnValue(of(new HttpErrorResponse({ status: 500 }))); + const errorSpy = jest.spyOn(alertService, 'error'); + + const importAllButton = fixture.nativeElement.querySelector('#importAllCompetenciesButton'); + importAllButton.click(); + + await fixture.whenStable(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(errorSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-alert.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-alert.service.ts index bf8bddef3463..5d761a4c91b4 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-alert.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-alert.service.ts @@ -4,4 +4,5 @@ export class MockAlertService { success = (message: string) => ({}) as Alert; error = (message: string) => ({}) as Alert; addAlert = (alert: Alert) => {}; + warning = (message: string) => ({}) as Alert; } diff --git a/src/test/javascript/spec/service/course-competency/course-competency-api.service.spec.ts b/src/test/javascript/spec/service/course-competency/course-competency-api.service.spec.ts index fc33eb3fff62..a46f72eeb70f 100644 --- a/src/test/javascript/spec/service/course-competency/course-competency-api.service.spec.ts +++ b/src/test/javascript/spec/service/course-competency/course-competency-api.service.spec.ts @@ -91,11 +91,11 @@ describe('CourseCompetencyApiService', () => { await methodCall; }); - it('should get course competencies by course id', async () => { - const methodCall = courseCompetencyApiService.getCourseCompetenciesByCourseId(courseId); + it('should get course competencies with student progress by course id', async () => { + const methodCall = courseCompetencyApiService.getCourseCompetenciesWithStudentProgressByCourseId(courseId); const response = httpClient.expectOne({ method: 'GET', - url: `${getBasePath(courseId)}`, + url: `${getBasePath(courseId)}-student-progress`, }); response.flush([]); await methodCall; From 217228b5e5863903f010d18635b055a658192e59 Mon Sep 17 00:00:00 2001 From: Johannes Wiest Date: Thu, 14 Nov 2024 10:13:52 +0100 Subject: [PATCH 6/9] fix client test compilation --- .../course-competencies-management-table.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts b/src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts index bd1aa490c072..0c081ddfc553 100644 --- a/src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts +++ b/src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts @@ -272,7 +272,7 @@ describe('CourseCompetenciesManagementTable', () => { }, result: modalResult, } as any); - jest.spyOn(competencyService, 'importAll').mockReturnValue(of(new HttpErrorResponse({ status: 500 }))); + jest.spyOn(competencyService, 'importAll').mockReturnValue(of(new HttpErrorResponse({ status: 500 }))); const errorSpy = jest.spyOn(alertService, 'error'); const importAllButton = fixture.nativeElement.querySelector('#importAllCompetenciesButton'); From 2b733aad2666c28a6ff76784b0c24a1b990ef562 Mon Sep 17 00:00:00 2001 From: Johannes Wiest Date: Thu, 14 Nov 2024 10:17:34 +0100 Subject: [PATCH 7/9] fix client test v2 --- .../course-competencies-management-table.component.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts b/src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts index 0c081ddfc553..9fa5ec2b6417 100644 --- a/src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts +++ b/src/test/javascript/spec/component/competencies/components/course-competencies-management-table.component.spec.ts @@ -3,7 +3,7 @@ import { TranslateService } from '@ngx-translate/core'; import { CourseCompetenciesManagementTableComponent } from 'app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component'; import { CompetencyWithTailRelationDTO, CourseCompetency, CourseCompetencyStudentProgressDTO, CourseCompetencyType } from 'app/entities/competency.model'; import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; -import { HttpErrorResponse, HttpResponse, provideHttpClient } from '@angular/common/http'; +import { HttpResponse, provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { MockLocalStorageService } from '../../../helpers/mocks/service/mock-local-storage.service'; import { LocalStorageService } from 'ngx-webstorage'; @@ -272,7 +272,7 @@ describe('CourseCompetenciesManagementTable', () => { }, result: modalResult, } as any); - jest.spyOn(competencyService, 'importAll').mockReturnValue(of(new HttpErrorResponse({ status: 500 }))); + jest.spyOn(competencyService, 'importAll').mockReturnValue(of(new HttpResponse({ status: 500 }))); const errorSpy = jest.spyOn(alertService, 'error'); const importAllButton = fixture.nativeElement.querySelector('#importAllCompetenciesButton'); From 46d90707368156eda8933b3275c9007e8387ae88 Mon Sep 17 00:00:00 2001 From: Johannes Wiest Date: Thu, 14 Nov 2024 10:46:21 +0100 Subject: [PATCH 8/9] remove generate Button from table --- ...mpetencies-management-table.component.html | 6 --- ...competencies-management-table.component.ts | 33 +------------- ...tencies-management-table.component.spec.ts | 45 +------------------ 3 files changed, 3 insertions(+), 81 deletions(-) diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.html b/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.html index a10bec0d1005..717b9adbae91 100644 --- a/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.html +++ b/src/main/webapp/app/course/competencies/components/course-competencies-management-table/course-competencies-management-table.component.html @@ -1,12 +1,6 @@
- @if (irisCompetencyGenerationEnabled()) { - - - {{ 'artemisApp.competency.manageCompetencies.generateButton' | artemisTranslate }} - - }