From 20a4a10e847e3cc402864b0f69a77e9b741058c9 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Thu, 21 Sep 2023 13:26:39 +0200 Subject: [PATCH 01/17] `Development`: Update bamboo version in build.gradle (#7237) --- build.gradle | 2 +- docker/atlassian.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index bd20786608f9..cedc5276d924 100644 --- a/build.gradle +++ b/build.gradle @@ -246,7 +246,7 @@ dependencies { implementation "org.imsglobal:basiclti-util:1.2.0" implementation "org.jasypt:jasypt:1.9.3" implementation "me.xdrop:fuzzywuzzy:1.4.0" - implementation "com.atlassian.bamboo:bamboo-specs:9.2.1" + implementation "com.atlassian.bamboo:bamboo-specs:9.3.3" implementation ("org.yaml:snakeyaml") { version { strictly "1.33" // needed for Bamboo-specs and to reduce the number of vulnerabilities, also see https://mvnrepository.com/artifact/org.yaml/snakeyaml diff --git a/docker/atlassian.yml b/docker/atlassian.yml index 592f282eb790..cf5b1047797b 100644 --- a/docker/atlassian.yml +++ b/docker/atlassian.yml @@ -24,7 +24,7 @@ services: hostname: bitbucket extra_hosts: - "host.docker.internal:host-gateway" - image: ghcr.io/ls1intum/artemis-bitbucket:8.8.2 + image: ghcr.io/ls1intum/artemis-bitbucket:8.13.1 pull_policy: always volumes: - artemis-bitbucket-data:/var/atlassian/application-data/bitbucket @@ -44,7 +44,7 @@ services: hostname: bamboo extra_hosts: - "host.docker.internal:host-gateway" - image: ghcr.io/ls1intum/artemis-bamboo:9.2.1 + image: ghcr.io/ls1intum/artemis-bamboo:9.3.3 pull_policy: always volumes: - artemis-bamboo-data:/var/atlassian/application-data/bamboo From c63bb50ab9d05c894b56624bca1b39e5f84f71b6 Mon Sep 17 00:00:00 2001 From: Maximilian Anzinger <44003963+MaximilianAnzinger@users.noreply.github.com> Date: Thu, 21 Sep 2023 16:59:04 +0200 Subject: [PATCH 02/17] Adaptive learning: Add graph details view to learning path management dashboard (#7027) --- build.gradle | 1 + package-lock.json | 10 - package.json | 4 +- .../domain/lecture/AttachmentUnit.java | 6 + .../artemis/domain/lecture/ExerciseUnit.java | 6 + .../artemis/domain/lecture/LectureUnit.java | 3 + .../artemis/domain/lecture/OnlineUnit.java | 6 + .../www1/artemis/domain/lecture/TextUnit.java | 6 + .../artemis/domain/lecture/VideoUnit.java | 6 + .../repository/LearningPathRepository.java | 15 ++ .../artemis/service/LearningPathService.java | 234 +++++++++++++++++- .../web/rest/LearningPathResource.java | 44 +++- .../dto/competency/NgxLearningPathDTO.java | 43 ++++ ...tureUnitForLearningPathNodeDetailsDTO.java | 12 + .../web/rest/lecture/LectureUnitResource.java | 15 ++ .../learning-path-graph-node.component.html | 52 ++++ .../learning-path-graph-node.component.ts | 33 +++ .../learning-path-graph.component.html | 40 +++ .../learning-path-graph.component.scss | 54 ++++ .../learning-path-graph.component.ts | 63 +++++ .../competency-node-details.component.html | 18 ++ .../competency-node-details.component.ts | 65 +++++ .../exercise-node-details.component.html | 14 ++ .../exercise-node-details.component.ts | 43 ++++ .../lecture-unit-node-details.component.html | 14 ++ .../lecture-unit-node-details.component.ts | 45 ++++ .../learning-path-management.component.html | 2 +- .../learning-path-management.component.ts | 14 +- ...earning-path-progress-modal.component.html | 19 ++ ...earning-path-progress-modal.component.scss | 26 ++ .../learning-path-progress-modal.component.ts | 20 ++ .../learning-path-progress-nav.component.html | 16 ++ .../learning-path-progress-nav.component.ts | 18 ++ .../learning-paths/learning-path.service.ts | 16 ++ .../learning-paths/learning-paths.module.ts | 23 +- .../competency/learning-path.model.ts | 30 +++ .../lecture-unit/lectureUnit.model.ts | 38 +++ .../lectureUnit.service.ts | 8 +- src/main/webapp/app/shared/shared.module.ts | 3 + .../sticky-popover.directive.ts | 119 +++++++++ src/main/webapp/i18n/de/competency.json | 5 + src/main/webapp/i18n/en/competency.json | 5 + .../lecture/LearningPathIntegrationTest.java | 50 ++++ .../lecture/LectureUnitIntegrationTest.java | 13 + .../service/LearningPathServiceTest.java | 212 +++++++++++++++- ...learning-path-graph-node.component.spec.ts | 46 ++++ .../learning-path-graph.component.spec.ts | 61 +++++ .../competency-node-details.component.spec.ts | 75 ++++++ .../exercise-node-details.component.spec.ts | 55 ++++ ...ecture-unit-node-details.component.spec.ts | 57 +++++ ...ning-path-progress-modal.component.spec.ts | 47 ++++ ...arning-path-progress-nav.component.spec.ts | 72 ++++++ .../lecture-unit/lecture-unit.service.spec.ts | 5 + .../sticky-popover.directive.spec.ts | 74 ++++++ .../service/learning-path.service.spec.ts | 6 + 55 files changed, 1966 insertions(+), 21 deletions(-) create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/NgxLearningPathDTO.java create mode 100644 src/main/java/de/tum/in/www1/artemis/web/rest/dto/lectureunit/LectureUnitForLearningPathNodeDetailsDTO.java create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.html create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.html create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.scss create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component.html create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component.html create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.html create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.scss create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.html create mode 100644 src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.ts create mode 100644 src/main/webapp/app/shared/sticky-popover/sticky-popover.directive.ts create mode 100644 src/test/javascript/spec/component/learning-paths/graph/learning-path-graph-node.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/graph/learning-path-graph.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/graph/node-details/competency-node-details.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/graph/node-details/exercise-node-details.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/graph/node-details/lecture-unit-node-details.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/management/learning-path-progress-modal.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/management/learning-path-progress-nav.component.spec.ts create mode 100644 src/test/javascript/spec/directive/sticky-popover.directive.spec.ts diff --git a/build.gradle b/build.gradle index cedc5276d924..9402b38d4c0f 100644 --- a/build.gradle +++ b/build.gradle @@ -346,6 +346,7 @@ dependencies { implementation "org.commonmark:commonmark:0.21.0" implementation "commons-fileupload:commons-fileupload:1.5" implementation "net.lingala.zip4j:zip4j:2.11.5" + implementation "org.jgrapht:jgrapht-core:1.5.2" annotationProcessor "org.hibernate:hibernate-jpamodelgen:${hibernate_version}" diff --git a/package-lock.json b/package-lock.json index f5c64d20b838..c166a8aabf1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5960,11 +5960,6 @@ "d3-time-format": "2 - 3" } }, - "node_modules/@swimlane/ngx-graph/node_modules/d3-selection": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", - "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==" - }, "node_modules/@swimlane/ngx-graph/node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", @@ -21023,11 +21018,6 @@ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" }, - "node_modules/webcola/node_modules/d3-selection": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", - "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==" - }, "node_modules/webcola/node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", diff --git a/package.json b/package.json index 10985c77984e..b83528845771 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,9 @@ "@swimlane/ngx-graph": { "d3-color": "^3.1.0", "d3-interpolate": "^3.0.1", - "d3-brush": "^3.0.0" + "d3-transition": "^3.0.0", + "d3-brush": "^3.0.0", + "d3-selection": "^3.0.0" }, "critters": "0.0.20", "semver": "7.5.4", diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lecture/AttachmentUnit.java b/src/main/java/de/tum/in/www1/artemis/domain/lecture/AttachmentUnit.java index 15f80c06ed0a..be2835971dac 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lecture/AttachmentUnit.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lecture/AttachmentUnit.java @@ -81,4 +81,10 @@ public ZonedDateTime getReleaseDate() { public void setReleaseDate(ZonedDateTime releaseDate) { // Should be set in associated attachment } + + // IMPORTANT NOTICE: The following string has to be consistent with the one defined in LectureUnit.java + @Override + public String getType() { + return "attachment"; + } } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lecture/ExerciseUnit.java b/src/main/java/de/tum/in/www1/artemis/domain/lecture/ExerciseUnit.java index cf25599d9e54..5520cbd8c260 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lecture/ExerciseUnit.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lecture/ExerciseUnit.java @@ -80,4 +80,10 @@ public void prePersistOrUpdate() { this.releaseDate = null; this.competencies = new HashSet<>(); } + + // IMPORTANT NOTICE: The following string has to be consistent with the one defined in LectureUnit.java + @Override + public String getType() { + return "exercise"; + } } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lecture/LectureUnit.java b/src/main/java/de/tum/in/www1/artemis/domain/lecture/LectureUnit.java index d9afc184abcf..d53653d4f53f 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lecture/LectureUnit.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lecture/LectureUnit.java @@ -121,4 +121,7 @@ public boolean isCompletedFor(User user) { public Optional getCompletionDate(User user) { return getCompletedUsers().stream().filter(completion -> completion.getUser().getId().equals(user.getId())).map(LectureUnitCompletion::getCompletedAt).findFirst(); } + + // Used to distinguish the type when used in a DTO, e.g., LectureUnitForLearningPathNodeDetailsDTO. + public abstract String getType(); } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lecture/OnlineUnit.java b/src/main/java/de/tum/in/www1/artemis/domain/lecture/OnlineUnit.java index dd401b3e2da4..07bc6022159a 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lecture/OnlineUnit.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lecture/OnlineUnit.java @@ -32,4 +32,10 @@ public String getDescription() { public void setDescription(String description) { this.description = description; } + + // IMPORTANT NOTICE: The following string has to be consistent with the one defined in LectureUnit.java + @Override + public String getType() { + return "online"; + } } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lecture/TextUnit.java b/src/main/java/de/tum/in/www1/artemis/domain/lecture/TextUnit.java index 30b13b76af42..205a1ff7cf12 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lecture/TextUnit.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lecture/TextUnit.java @@ -19,4 +19,10 @@ public String getContent() { public void setContent(String content) { this.content = content; } + + // IMPORTANT NOTICE: The following string has to be consistent with the one defined in LectureUnit.java + @Override + public String getType() { + return "text"; + } } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lecture/VideoUnit.java b/src/main/java/de/tum/in/www1/artemis/domain/lecture/VideoUnit.java index ea5386ca1654..7b38762198ca 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lecture/VideoUnit.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lecture/VideoUnit.java @@ -30,4 +30,10 @@ public String getDescription() { public void setDescription(String description) { this.description = description; } + + // IMPORTANT NOTICE: The following string has to be consistent with the one defined in LectureUnit.java + @Override + public String getType() { + return "video"; + } } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/LearningPathRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/LearningPathRepository.java index f48c5ee6b36c..5f5745217413 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/LearningPathRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/LearningPathRepository.java @@ -4,6 +4,8 @@ import java.util.Optional; +import javax.validation.constraints.NotNull; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; @@ -20,6 +22,10 @@ public interface LearningPathRepository extends JpaRepository findByCourseIdAndUserId(long courseId, long userId); + default LearningPath findByCourseIdAndUserIdElseThrow(long courseId, long userId) { + return findByCourseIdAndUserId(courseId, userId).orElseThrow(() -> new EntityNotFoundException("LearningPath")); + } + @EntityGraph(type = LOAD, attributePaths = { "competencies" }) Optional findWithEagerCompetenciesByCourseIdAndUserId(long courseId, long userId); @@ -40,4 +46,13 @@ SELECT COUNT (learningPath) WHERE learningPath.course.id = :courseId AND learningPath.user.isDeleted = false AND learningPath.course.studentGroupName MEMBER OF learningPath.user.groups """) long countLearningPathsOfEnrolledStudentsInCourse(@Param("courseId") long courseId); + + @EntityGraph(type = LOAD, attributePaths = { "competencies", "competencies.lectureUnits", "competencies.lectureUnits.completedUsers", "competencies.exercises", + "competencies.exercises.studentParticipations" }) + Optional findWithEagerCompetenciesAndLearningObjectsAndCompletedUsersById(long learningPathId); + + @NotNull + default LearningPath findWithEagerCompetenciesAndLearningObjectsAndCompletedUsersByIdElseThrow(long learningPathId) { + return findWithEagerCompetenciesAndLearningObjectsAndCompletedUsersById(learningPathId).orElseThrow(() -> new EntityNotFoundException("LearningPath", learningPathId)); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/LearningPathService.java b/src/main/java/de/tum/in/www1/artemis/service/LearningPathService.java index 33287ecf4380..2329e5c5f757 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/LearningPathService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/LearningPathService.java @@ -1,10 +1,14 @@ package de.tum.in.www1.artemis.service; -import java.util.List; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import java.util.stream.LongStream; +import java.util.stream.Stream; import javax.validation.constraints.NotNull; +import org.jgrapht.alg.util.UnionFind; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; @@ -13,12 +17,14 @@ import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.competency.Competency; +import de.tum.in.www1.artemis.domain.competency.CompetencyRelation; import de.tum.in.www1.artemis.domain.competency.LearningPath; import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.web.rest.dto.PageableSearchDTO; import de.tum.in.www1.artemis.web.rest.dto.SearchResultPageDTO; import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathHealthDTO; import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathPageableSearchDTO; +import de.tum.in.www1.artemis.web.rest.dto.competency.NgxLearningPathDTO; import de.tum.in.www1.artemis.web.rest.util.PageUtil; /** @@ -43,12 +49,15 @@ public class LearningPathService { private final CourseRepository courseRepository; + private final CompetencyRelationRepository competencyRelationRepository; + public LearningPathService(UserRepository userRepository, LearningPathRepository learningPathRepository, CompetencyProgressRepository competencyProgressRepository, - CourseRepository courseRepository) { + CourseRepository courseRepository, CompetencyRelationRepository competencyRelationRepository) { this.userRepository = userRepository; this.learningPathRepository = learningPathRepository; this.competencyProgressRepository = competencyProgressRepository; this.courseRepository = courseRepository; + this.competencyRelationRepository = competencyRelationRepository; } /** @@ -182,4 +191,225 @@ public LearningPathHealthDTO getHealthStatusForCourse(@NotNull Course course) { return new LearningPathHealthDTO(LearningPathHealthDTO.HealthStatus.MISSING, numberOfStudents - numberOfLearningPaths); } } + + /** + * Generates Ngx graph representation of the learning path graph. + * + * @param learningPath the learning path for which the Ngx representation should be created + * @return Ngx graph representation of the learning path + * @see NgxLearningPathDTO + */ + public NgxLearningPathDTO generateNgxGraphRepresentation(@NotNull LearningPath learningPath) { + Set nodes = new HashSet<>(); + Set edges = new HashSet<>(); + learningPath.getCompetencies().forEach(competency -> generateNgxGraphRepresentationForCompetency(learningPath, competency, nodes, edges)); + generateNgxGraphRepresentationForRelations(learningPath, nodes, edges); + return new NgxLearningPathDTO(nodes, edges); + } + + /** + * Generates Ngx graph representation for competency. + *

+ * A competency's representation consists of + *

    + *
  • start node
  • + *
  • end node
  • + *
  • a node for each learning unit (exercises or lecture unit)
  • + *
  • edges from start node to each learning unit
  • + *
  • edges from each learning unit to end node
  • + *
+ * + * @param learningPath the learning path for which the representation should be created + * @param competency the competency for which the representation will be created + * @param nodes set of nodes to store the new nodes + * @param edges set of edges to store the new edges + */ + private void generateNgxGraphRepresentationForCompetency(LearningPath learningPath, Competency competency, Set nodes, + Set edges) { + Set currentCluster = new HashSet<>(); + // generates start and end node + final var startNodeId = getCompetencyStartNodeId(competency.getId()); + final var endNodeId = getCompetencyEndNodeId(competency.getId()); + currentCluster.add(new NgxLearningPathDTO.Node(startNodeId, NgxLearningPathDTO.NodeType.COMPETENCY_START, competency.getId())); + currentCluster.add(new NgxLearningPathDTO.Node(endNodeId, NgxLearningPathDTO.NodeType.COMPETENCY_END, competency.getId())); + + // generate nodes and edges for lecture units + competency.getLectureUnits().forEach(lectureUnit -> { + currentCluster.add(new NgxLearningPathDTO.Node(getLectureUnitNodeId(competency.getId(), lectureUnit.getId()), NgxLearningPathDTO.NodeType.LECTURE_UNIT, + lectureUnit.getId(), lectureUnit.getLecture().getId(), lectureUnit.isCompletedFor(learningPath.getUser()), lectureUnit.getName())); + edges.add(new NgxLearningPathDTO.Edge(getLectureUnitInEdgeId(competency.getId(), lectureUnit.getId()), startNodeId, + getLectureUnitNodeId(competency.getId(), lectureUnit.getId()))); + edges.add(new NgxLearningPathDTO.Edge(getLectureUnitOutEdgeId(competency.getId(), lectureUnit.getId()), getLectureUnitNodeId(competency.getId(), lectureUnit.getId()), + endNodeId)); + }); + // generate nodes and edges for exercises + competency.getExercises().forEach(exercise -> { + currentCluster.add(new NgxLearningPathDTO.Node(getExerciseNodeId(competency.getId(), exercise.getId()), NgxLearningPathDTO.NodeType.EXERCISE, exercise.getId(), + exercise.isCompletedFor(learningPath.getUser()), exercise.getTitle())); + edges.add(new NgxLearningPathDTO.Edge(getExerciseInEdgeId(competency.getId(), exercise.getId()), startNodeId, getExerciseNodeId(competency.getId(), exercise.getId()))); + edges.add(new NgxLearningPathDTO.Edge(getExerciseOutEdgeId(competency.getId(), exercise.getId()), getExerciseNodeId(competency.getId(), exercise.getId()), endNodeId)); + }); + // if no linked learning units exist directly link start to end + if (currentCluster.size() == 2) { + edges.add(new NgxLearningPathDTO.Edge(getDirectEdgeId(competency.getId()), startNodeId, endNodeId)); + } + + nodes.addAll(currentCluster); + } + + /** + * Generates Ngx graph representations for competency relations. + *

+ * The representation will contain: + *

    + *
  • + * For each matching cluster (transitive closure of competencies that are in a match relation): + *
      + *
    • two nodes (start and end of cluster) will be created
    • + *
    • edges from the start node of the cluster to each start node of the competencies
    • + *
    • edges from each end node of the competency to the end node of the cluster
    • + *
    + *
  • + *
  • + * For each other relation: edge from head competency end node to tail competency start node. If competency is part of a matching cluster, the edge will be linked to the + * corresponding cluster start/end node. + *
  • + *
+ * + * two nodes (start and end of cluster) will be created. + * + * @param learningPath the learning path for which the Ngx representation should be created + * @param nodes set of nodes to store the new nodes + * @param edges set of edges to store the new edges + */ + private void generateNgxGraphRepresentationForRelations(LearningPath learningPath, Set nodes, Set edges) { + final var relations = competencyRelationRepository.findAllByCourseId(learningPath.getCourse().getId()); + + // compute match clusters + Map competencyToMatchCluster = new HashMap<>(); + final var competenciesInMatchRelation = relations.stream().filter(relation -> relation.getType().equals(CompetencyRelation.RelationType.MATCHES)) + .flatMap(relation -> Stream.of(relation.getHeadCompetency().getId(), relation.getTailCompetency().getId())).collect(Collectors.toSet()); + if (!competenciesInMatchRelation.isEmpty()) { + UnionFind matchClusters = new UnionFind<>(competenciesInMatchRelation); + relations.stream().filter(relation -> relation.getType().equals(CompetencyRelation.RelationType.MATCHES)) + .forEach(relation -> matchClusters.union(relation.getHeadCompetency().getId(), relation.getTailCompetency().getId())); + + // generate map between competencies and cluster node + AtomicInteger matchClusterId = new AtomicInteger(); + relations.stream().filter(relation -> relation.getType().equals(CompetencyRelation.RelationType.MATCHES)) + .flatMapToLong(relation -> LongStream.of(relation.getHeadCompetency().getId(), relation.getTailCompetency().getId())).distinct().forEach(competencyId -> { + var parentId = matchClusters.find(competencyId); + var clusterId = competencyToMatchCluster.computeIfAbsent(parentId, (key) -> matchClusterId.getAndIncrement()); + competencyToMatchCluster.put(competencyId, clusterId); + }); + + // generate match cluster start and end nodes + for (int i = 0; i < matchClusters.numberOfSets(); i++) { + nodes.add(new NgxLearningPathDTO.Node(getMatchingClusterStartNodeId(i), NgxLearningPathDTO.NodeType.MATCH_START)); + nodes.add(new NgxLearningPathDTO.Node(getMatchingClusterEndNodeId(i), NgxLearningPathDTO.NodeType.MATCH_END)); + } + + // generate edges between match cluster nodes and corresponding competencies + competencyToMatchCluster.forEach((competency, cluster) -> { + edges.add(new NgxLearningPathDTO.Edge(getInEdgeId(competency), getMatchingClusterStartNodeId(cluster), getCompetencyStartNodeId(competency))); + edges.add(new NgxLearningPathDTO.Edge(getOutEdgeId(competency), getCompetencyEndNodeId(competency), getMatchingClusterEndNodeId(cluster))); + }); + } + + // generate edges for remaining relations + final Set createdRelations = new HashSet<>(); + relations.stream().filter(relation -> !relation.getType().equals(CompetencyRelation.RelationType.MATCHES)) + .forEach(relation -> generateNgxGraphRepresentationForRelation(relation, competencyToMatchCluster, createdRelations, edges)); + } + + /** + * Generates Ngx graph representations for competency relation. + * + * @param relation the relation for which the Ngx representation should be created + * @param competencyToMatchCluster map from competencies to corresponding cluster + * @param createdRelations set of edge ids that have already been created + * @param edges set of edges to store the new edges + */ + private void generateNgxGraphRepresentationForRelation(CompetencyRelation relation, Map competencyToMatchCluster, Set createdRelations, + Set edges) { + final var sourceId = relation.getHeadCompetency().getId(); + String sourceNodeId; + if (competencyToMatchCluster.containsKey(sourceId)) { + sourceNodeId = getMatchingClusterEndNodeId(competencyToMatchCluster.get(sourceId)); + } + else { + sourceNodeId = getCompetencyEndNodeId(sourceId); + } + final var targetId = relation.getTailCompetency().getId(); + String targetNodeId; + if (competencyToMatchCluster.containsKey(targetId)) { + targetNodeId = getMatchingClusterStartNodeId(competencyToMatchCluster.get(targetId)); + } + else { + targetNodeId = getCompetencyStartNodeId(targetId); + } + final String relationEdgeId = getRelationEdgeId(sourceNodeId, targetNodeId); + // skip if relation has already been created (possible for edges linked to matching cluster start/end nodes) + if (!createdRelations.contains(relationEdgeId)) { + final var edge = new NgxLearningPathDTO.Edge(relationEdgeId, sourceNodeId, targetNodeId); + edges.add(edge); + createdRelations.add(relationEdgeId); + } + } + + public static String getCompetencyStartNodeId(long competencyId) { + return "node-" + competencyId + "-start"; + } + + public static String getCompetencyEndNodeId(long competencyId) { + return "node-" + competencyId + "-end"; + } + + public static String getLectureUnitNodeId(long competencyId, long lectureUnitId) { + return "node-" + competencyId + "-lu-" + lectureUnitId; + } + + public static String getExerciseNodeId(long competencyId, long exerciseId) { + return "node-" + competencyId + "-ex-" + exerciseId; + } + + public static String getMatchingClusterStartNodeId(long matchingClusterId) { + return "matching-" + matchingClusterId + "-start"; + } + + public static String getMatchingClusterEndNodeId(long matchingClusterId) { + return "matching-" + matchingClusterId + "-end"; + } + + public static String getLectureUnitInEdgeId(long competencyId, long lectureUnitId) { + return "edge-" + competencyId + "-lu-" + getInEdgeId(lectureUnitId); + } + + public static String getLectureUnitOutEdgeId(long competencyId, long lectureUnitId) { + return "edge-" + competencyId + "-lu-" + getOutEdgeId(lectureUnitId); + } + + public static String getExerciseInEdgeId(long competencyId, long exercise) { + return "edge-" + competencyId + "-ex-" + getInEdgeId(exercise); + } + + public static String getExerciseOutEdgeId(long competencyId, long exercise) { + return "edge-" + competencyId + "-ex-" + getOutEdgeId(exercise); + } + + public static String getInEdgeId(long id) { + return "edge-" + id + "-in"; + } + + public static String getOutEdgeId(long id) { + return "edge-" + id + "-out"; + } + + public static String getRelationEdgeId(String sourceNodeId, String targetNodeId) { + return "edge-relation-" + sourceNodeId + "-" + targetNodeId; + } + + public static String getDirectEdgeId(long competencyId) { + return "edge-" + competencyId + "-direct"; + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java index bfef936c606d..be11819bc2aa 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LearningPathResource.java @@ -8,9 +8,14 @@ import org.springframework.web.bind.annotation.*; import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.competency.LearningPath; import de.tum.in.www1.artemis.repository.CourseRepository; +import de.tum.in.www1.artemis.repository.LearningPathRepository; +import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor; +import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastStudent; import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.LearningPathService; import de.tum.in.www1.artemis.service.feature.Feature; @@ -19,6 +24,7 @@ import de.tum.in.www1.artemis.web.rest.dto.SearchResultPageDTO; import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathHealthDTO; import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathPageableSearchDTO; +import de.tum.in.www1.artemis.web.rest.dto.competency.NgxLearningPathDTO; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; @RestController @@ -33,10 +39,17 @@ public class LearningPathResource { private final LearningPathService learningPathService; - public LearningPathResource(CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, LearningPathService learningPathService) { + private final LearningPathRepository learningPathRepository; + + private final UserRepository userRepository; + + public LearningPathResource(CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, LearningPathService learningPathService, + LearningPathRepository learningPathRepository, UserRepository userRepository) { this.courseRepository = courseRepository; this.authorizationCheckService = authorizationCheckService; this.learningPathService = learningPathService; + this.learningPathRepository = learningPathRepository; + this.userRepository = userRepository; } /** @@ -123,4 +136,33 @@ public ResponseEntity getHealthStatusForCourse(@PathVaria return ResponseEntity.ok(learningPathService.getHealthStatusForCourse(course)); } + + /** + * GET /learning-path/:learningPathId/graph : Gets the ngx representation of the learning path. + * + * @param learningPathId the id of the learning path that should be fetched + * @return the ResponseEntity with status 200 (OK) and with body the ngx representation of the learning path + */ + @GetMapping("/learning-path/{learningPathId}/graph") + @FeatureToggle(Feature.LearningPaths) + @EnforceAtLeastStudent + public ResponseEntity getLearningPathNgxGraph(@PathVariable Long learningPathId) { + log.debug("REST request to get ngx representation of learning path with id: {}", learningPathId); + LearningPath learningPath = learningPathRepository.findWithEagerCompetenciesAndLearningObjectsAndCompletedUsersByIdElseThrow(learningPathId); + Course course = courseRepository.findByIdElseThrow(learningPath.getCourse().getId()); + if (!course.getLearningPathsEnabled()) { + throw new BadRequestException("Learning paths are not enabled for this course."); + } + User user = userRepository.getUserWithGroupsAndAuthorities(); + if (authorizationCheckService.isStudentInCourse(course, user)) { + if (!user.getId().equals(learningPath.getUser().getId())) { + throw new AccessForbiddenException("You are not allowed to access another users learning path."); + } + } + else if (!authorizationCheckService.isAtLeastInstructorInCourse(course, user) && !authorizationCheckService.isAdmin()) { + throw new AccessForbiddenException("You are not allowed to access another users learning path."); + } + NgxLearningPathDTO graph = learningPathService.generateNgxGraphRepresentation(learningPath); + return ResponseEntity.ok(graph); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/NgxLearningPathDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/NgxLearningPathDTO.java new file mode 100644 index 000000000000..06b36697fd75 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/competency/NgxLearningPathDTO.java @@ -0,0 +1,43 @@ +package de.tum.in.www1.artemis.web.rest.dto.competency; + +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Represents simplified learning path optimized for Ngx representation + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record NgxLearningPathDTO(Set nodes, Set edges) { + + public record Node(String id, NodeType type, Long linkedResource, Long linkedResourceParent, boolean completed, String label) { + + public Node(String id, NodeType type, Long linkedResource, boolean completed, String label) { + this(id, type, linkedResource, null, completed, label); + } + + public Node(String id, NodeType type, Long linkedResource, String label) { + this(id, type, linkedResource, false, label); + } + + public Node(String id, NodeType type, String label) { + this(id, type, null, label); + } + + public Node(String id, NodeType type, Long linkedResource) { + this(id, type, linkedResource, ""); + } + + public Node(String id, NodeType type) { + this(id, type, null, ""); + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Edge(String id, String source, String target) { + } + + public enum NodeType { + COMPETENCY_START, COMPETENCY_END, MATCH_START, MATCH_END, EXERCISE, LECTURE_UNIT, + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/lectureunit/LectureUnitForLearningPathNodeDetailsDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/lectureunit/LectureUnitForLearningPathNodeDetailsDTO.java new file mode 100644 index 000000000000..1161edf68de8 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/lectureunit/LectureUnitForLearningPathNodeDetailsDTO.java @@ -0,0 +1,12 @@ +package de.tum.in.www1.artemis.web.rest.dto.lectureunit; + +import javax.validation.constraints.NotNull; + +import de.tum.in.www1.artemis.domain.lecture.LectureUnit; + +public record LectureUnitForLearningPathNodeDetailsDTO(long id, @NotNull String name, @NotNull String type) { + + public static LectureUnitForLearningPathNodeDetailsDTO of(@NotNull LectureUnit lectureUnit) { + return new LectureUnitForLearningPathNodeDetailsDTO(lectureUnit.getId(), lectureUnit.getName(), lectureUnit.getType()); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/lecture/LectureUnitResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/lecture/LectureUnitResource.java index 0881bdb29a17..57fbd7abb92d 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/lecture/LectureUnitResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/lecture/LectureUnitResource.java @@ -22,6 +22,7 @@ import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.CompetencyProgressService; import de.tum.in.www1.artemis.service.LectureUnitService; +import de.tum.in.www1.artemis.web.rest.dto.lectureunit.LectureUnitForLearningPathNodeDetailsDTO; import de.tum.in.www1.artemis.web.rest.errors.ConflictException; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; import de.tum.in.www1.artemis.web.rest.util.HeaderUtil; @@ -161,4 +162,18 @@ public ResponseEntity deleteLectureUnit(@PathVariable Long lectureUnitId, return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, lectureUnitName)).build(); } + /** + * GET /lecture-units/:lectureUnitId/for-learning-path-node-details : Gets lecture unit for the details view of a learning path node. + * + * @param lectureUnitId the id of the lecture unit that should be fetched + * @return the ResponseEntity with status 200 (OK) + */ + @GetMapping("/lecture-units/{lectureUnitId}/for-learning-path-node-details") + @EnforceAtLeastStudent + public ResponseEntity getLectureUnitForLearningPathNodeDetails(@PathVariable long lectureUnitId) { + log.info("REST request to get lecture unit for learning path node details with id: {}", lectureUnitId); + LectureUnit lectureUnit = lectureUnitRepository.findById(lectureUnitId).orElseThrow(); + authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, lectureUnit.getLecture().getCourse(), null); + return ResponseEntity.ok(LectureUnitForLearningPathNodeDetailsDTO.of(lectureUnit)); + } } diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.html b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.html new file mode 100644 index 000000000000..3dfbe0a378a5 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.html @@ -0,0 +1,52 @@ +
+ + +
+ +
+ +
+
+ + +
+ +
+
+ + + + + + + + + diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.ts new file mode 100644 index 000000000000..d9771c27a43e --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph-node.component.ts @@ -0,0 +1,33 @@ +import { Component, Input } from '@angular/core'; +import { faCheckCircle, faCircle, faPlayCircle, faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; +import { NgxLearningPathNode, NodeType } from 'app/entities/competency/learning-path.model'; +import { Competency, CompetencyProgress } from 'app/entities/competency.model'; +import { Exercise } from 'app/entities/exercise.model'; +import { Lecture } from 'app/entities/lecture.model'; +import { LectureUnitForLearningPathNodeDetailsDTO } from 'app/entities/lecture-unit/lectureUnit.model'; + +class NodeDetailsData { + competency?: Competency; + competencyProgress?: CompetencyProgress; + exercise?: Exercise; + lecture?: Lecture; + lectureUnit?: LectureUnitForLearningPathNodeDetailsDTO; +} + +@Component({ + selector: 'jhi-learning-path-graph-node', + templateUrl: './learning-path-graph-node.component.html', +}) +export class LearningPathGraphNodeComponent { + @Input() courseId: number; + @Input() node: NgxLearningPathNode; + + faCheckCircle = faCheckCircle; + faPlayCircle = faPlayCircle; + faQuestionCircle = faQuestionCircle; + faCircle = faCircle; + + nodeDetailsData = new NodeDetailsData(); + + protected readonly NodeType = NodeType; +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.html b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.html new file mode 100644 index 000000000000..1e4f5fd6848f --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.html @@ -0,0 +1,40 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.scss b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.scss new file mode 100644 index 000000000000..36d2fb2ebd59 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.scss @@ -0,0 +1,54 @@ +.graph-container { + display: block; + width: 100%; + height: 100%; + overflow: hidden; + + .ngx-graph { + width: auto; + } + + .node { + display: flex; + width: 100%; + height: 100%; + } + + #arrow { + stroke: var(--body-color); + fill: var(--body-color); + } + + .edge { + stroke: var(--body-color) !important; + marker-end: url(#arrow); + } +} + +jhi-learning-path-graph-node:hover { + cursor: pointer; +} + +.node-icon-container { + width: 100%; + display: flex; + + fa-icon { + width: 100%; + + svg { + margin: 10%; + width: 80%; + height: 80%; + } + } +} + +.completed { + color: var(--bs-success); +} + +.node-details { + display: block; + max-width: 40vw; +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.ts new file mode 100644 index 000000000000..c7f8bda2b015 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-graph.component.ts @@ -0,0 +1,63 @@ +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Layout } from '@swimlane/ngx-graph'; +import * as shape from 'd3-shape'; +import { Subject } from 'rxjs'; +import { LearningPathService } from 'app/course/learning-paths/learning-path.service'; +import { NgxLearningPathDTO, NgxLearningPathNode } from 'app/entities/competency/learning-path.model'; + +@Component({ + selector: 'jhi-learning-path-graph', + styleUrls: ['./learning-path-graph.component.scss'], + templateUrl: './learning-path-graph.component.html', + encapsulation: ViewEncapsulation.None, +}) +export class LearningPathGraphComponent implements OnInit { + isLoading = false; + @Input() learningPathId: number; + @Input() courseId: number; + @Output() nodeClicked: EventEmitter = new EventEmitter(); + ngxLearningPath: NgxLearningPathDTO; + + layout: string | Layout = 'dagreCluster'; + curve = shape.curveBundle; + + draggingEnabled = false; + panningEnabled = true; + zoomEnabled = true; + panOnZoom = true; + + update$: Subject = new Subject(); + center$: Subject = new Subject(); + zoomToFit$: Subject = new Subject(); + + constructor( + private activatedRoute: ActivatedRoute, + private learningPathService: LearningPathService, + ) {} + + ngOnInit() { + if (this.learningPathId) { + this.loadData(); + } + } + + loadData() { + this.isLoading = true; + this.learningPathService.getLearningPathNgxGraph(this.learningPathId).subscribe((ngxLearningPathResponse) => { + this.ngxLearningPath = ngxLearningPathResponse.body!; + this.isLoading = false; + }); + } + + onResize() { + this.update$.next(true); + this.center$.next(true); + this.zoomToFit$.next(true); + } + + onCenterView() { + this.zoomToFit$.next(true); + this.center$.next(true); + } +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html new file mode 100644 index 000000000000..b31670b4d330 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html @@ -0,0 +1,18 @@ +
+
+

+ + {{ competency.title }} + Mastered + Optional +

+
{{ competency.description }}
+
+
+
diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.ts new file mode 100644 index 000000000000..1de146171245 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.ts @@ -0,0 +1,65 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { onError } from 'app/shared/util/global.utils'; +import { CompetencyService } from 'app/course/competencies/competency.service'; +import { Competency, CompetencyProgress, getIcon, getIconTooltip } from 'app/entities/competency.model'; +import { AlertService } from 'app/core/util/alert.service'; + +@Component({ + selector: 'jhi-competency-node-details', + templateUrl: './competency-node-details.component.html', +}) +export class CompetencyNodeDetailsComponent implements OnInit { + @Input() courseId: number; + @Input() competencyId: number; + @Input() competency?: Competency; + @Output() competencyChange = new EventEmitter(); + @Input() competencyProgress?: CompetencyProgress; + @Output() competencyProgressChange = new EventEmitter(); + + isLoading = false; + + constructor( + private competencyService: CompetencyService, + private alertService: AlertService, + ) {} + + ngOnInit() { + if (!this.competency || !this.competencyProgress) { + this.loadData(); + } + } + private loadData() { + this.isLoading = true; + this.competencyService.findById(this.competencyId!, this.courseId!).subscribe({ + next: (resp) => { + this.competency = resp.body!; + if (this.competency.userProgress?.length) { + this.competencyProgress = this.competency.userProgress.first()!; + } else { + this.competencyProgress = { progress: 0, confidence: 0 } as CompetencyProgress; + } + this.isLoading = false; + this.competencyChange.emit(this.competency); + this.competencyProgressChange.emit(this.competencyProgress); + }, + error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), + }); + } + + get progress(): number { + return Math.round(this.competencyProgress?.progress ?? 0); + } + + get confidence(): number { + return Math.min(Math.round(((this.competencyProgress?.confidence ?? 0) / (this.competency?.masteryThreshold ?? 100)) * 100), 100); + } + + get mastery(): number { + const weight = 2 / 3; + return Math.round((1 - weight) * this.progress + weight * this.confidence); + } + + protected readonly getIcon = getIcon; + protected readonly getIconTooltip = getIconTooltip; +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component.html b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component.html new file mode 100644 index 000000000000..d364b09a85b2 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component.html @@ -0,0 +1,14 @@ +
+
+

+ + {{ exercise.title }} +

+
+
diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component.ts new file mode 100644 index 000000000000..65259f79acfb --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component.ts @@ -0,0 +1,43 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { onError } from 'app/shared/util/global.utils'; +import { AlertService } from 'app/core/util/alert.service'; +import { Exercise, getIcon, getIconTooltip } from 'app/entities/exercise.model'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; + +@Component({ + selector: 'jhi-exercise-node-details', + templateUrl: './exercise-node-details.component.html', +}) +export class ExerciseNodeDetailsComponent implements OnInit { + @Input() exerciseId: number; + @Input() exercise?: Exercise; + @Output() exerciseChange = new EventEmitter(); + + isLoading = false; + + constructor( + private exerciseService: ExerciseService, + private alertService: AlertService, + ) {} + + ngOnInit() { + if (!this.exercise) { + this.loadData(); + } + } + private loadData() { + this.isLoading = true; + this.exerciseService.find(this.exerciseId).subscribe({ + next: (exerciseResponse) => { + this.exercise = exerciseResponse.body!; + this.isLoading = false; + this.exerciseChange.emit(this.exercise); + }, + error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), + }); + } + + protected readonly getIcon = getIcon; + protected readonly getIconTooltip = getIconTooltip; +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component.html b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component.html new file mode 100644 index 000000000000..4f6553e2ef8e --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component.html @@ -0,0 +1,14 @@ +
+
+

+ + {{ lectureUnit.name }} +

+
+
diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component.ts new file mode 100644 index 000000000000..300909fde0f7 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component.ts @@ -0,0 +1,45 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { onError } from 'app/shared/util/global.utils'; +import { AlertService } from 'app/core/util/alert.service'; +import { LectureUnit, getIcon, getIconTooltip } from 'app/entities/lecture-unit/lectureUnit.model'; +import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; + +@Component({ + selector: 'jhi-lecture-unit-node-details', + templateUrl: './lecture-unit-node-details.component.html', +}) +export class LectureUnitNodeDetailsComponent implements OnInit { + @Input() lectureUnitId: number; + + @Input() lectureUnit?: LectureUnit; + @Output() lectureUnitChange = new EventEmitter(); + + isLoading = false; + + constructor( + private lectureUnitService: LectureUnitService, + private alertService: AlertService, + ) {} + + ngOnInit() { + if (!this.lectureUnit) { + this.loadData(); + } + } + private loadData() { + this.isLoading = true; + + this.lectureUnitService.getLectureUnitForLearningPathNodeDetails(this.lectureUnitId!).subscribe({ + next: (lectureUnitResult) => { + this.lectureUnit = lectureUnitResult.body!; + this.isLoading = false; + this.lectureUnitChange.emit(this.lectureUnit); + }, + error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), + }); + } + + protected readonly getIcon = getIcon; + protected readonly getIconTooltip = getIconTooltip; +} 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 b0d9f165b87c..c6e89d1e7a8c 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 @@ -76,7 +76,7 @@
{{ 'artemisApp.learningPath.manageLearningPaths.health.mi - + 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 0cb7c855f550..60d0b168c6ec 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 @@ -12,6 +12,8 @@ import { SortService } from 'app/shared/service/sort.service'; import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; import { faSort, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { HealthStatus, LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { LearningPathProgressModalComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-modal.component'; export enum TableColumn { ID = 'ID', @@ -55,6 +57,7 @@ export class LearningPathManagementComponent implements OnInit { private alertService: AlertService, private pagingService: LearningPathPagingService, private sortService: SortService, + private modalService: NgbModal, ) {} get page(): number { @@ -199,8 +202,15 @@ export class LearningPathManagementComponent implements OnInit { this.page = pageNumber; } } - viewLearningPath() { - // todo: part of future pr + + viewLearningPath(learningPath: LearningPathPageableSearchDTO) { + const modalRef = this.modalService.open(LearningPathProgressModalComponent, { + size: 'xl', + backdrop: 'static', + windowClass: 'learning-path-modal', + }); + modalRef.componentInstance.courseId = this.courseId; + modalRef.componentInstance.learningPath = learningPath; } protected readonly HealthStatus = HealthStatus; diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.html b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.html new file mode 100644 index 000000000000..7477b90ced6c --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.html @@ -0,0 +1,19 @@ + diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.scss b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.scss new file mode 100644 index 000000000000..cc798cd46adb --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.scss @@ -0,0 +1,26 @@ +.modal-container { + display: flex; + flex-wrap: wrap; + height: 90vh; + width: 100%; + padding-top: 8px; + + .row { + width: 100%; + margin-left: 0; + margin-right: 0; + } + + .modal-nav { + height: max-content; + } + + .graph { + width: 100%; + overflow: hidden; + } +} + +.learning-path-modal .modal-dialog .modal-content { + min-height: 500px; +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.ts new file mode 100644 index 000000000000..090b7d5405f4 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-modal.component.ts @@ -0,0 +1,20 @@ +import { Component, Input, ViewChild } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; +import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; +@Component({ + selector: 'jhi-learning-path-progress-modal', + styleUrls: ['./learning-path-progress-modal.component.scss'], + templateUrl: './learning-path-progress-modal.component.html', +}) +export class LearningPathProgressModalComponent { + @Input() courseId: number; + @Input() learningPath: LearningPathPageableSearchDTO; + @ViewChild('learningPathGraphComponent') learningPathGraphComponent: LearningPathGraphComponent; + + constructor(private activeModal: NgbActiveModal) {} + + close() { + this.activeModal.close(); + } +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.html b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.html new file mode 100644 index 000000000000..66f5c6778206 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.html @@ -0,0 +1,16 @@ +
+
+

{{ learningPath.user?.name }} ({{ learningPath.user?.login }})

+
+
+ + + +
+
diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.ts new file mode 100644 index 000000000000..9b4a8d38c686 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.ts @@ -0,0 +1,18 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { faArrowsRotate, faArrowsToEye, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; + +@Component({ + selector: 'jhi-learning-path-progress-nav', + templateUrl: './learning-path-progress-nav.component.html', +}) +export class LearningPathProgressNavComponent { + @Input() learningPath: LearningPathPageableSearchDTO; + @Output() onRefresh: EventEmitter = new EventEmitter(); + @Output() onCenterView: EventEmitter = new EventEmitter(); + @Output() onClose: EventEmitter = new EventEmitter(); + + faXmark = faXmark; + faArrowsToEye = faArrowsToEye; + faArrowsRotate = faArrowsRotate; +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path.service.ts b/src/main/webapp/app/course/learning-paths/learning-path.service.ts index ece1bc6b204a..b201a57f84d3 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path.service.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path.service.ts @@ -2,6 +2,8 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { HttpClient, HttpResponse } from '@angular/common/http'; import { LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { NgxLearningPathDTO } from 'app/entities/competency/learning-path.model'; +import { map } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class LearningPathService { @@ -20,4 +22,18 @@ export class LearningPathService { getHealthStatusForCourse(courseId: number) { return this.httpClient.get(`${this.resourceURL}/courses/${courseId}/learning-path-health`, { observe: 'response' }); } + + getLearningPathNgxGraph(learningPathId: number): Observable> { + return this.httpClient.get(`${this.resourceURL}/learning-path/${learningPathId}/graph`, { observe: 'response' }).pipe( + map((ngxLearningPathResponse) => { + if (!ngxLearningPathResponse.body!.nodes) { + ngxLearningPathResponse.body!.nodes = []; + } + if (!ngxLearningPathResponse.body!.edges) { + ngxLearningPathResponse.body!.edges = []; + } + return ngxLearningPathResponse; + }), + ); + } } diff --git a/src/main/webapp/app/course/learning-paths/learning-paths.module.ts b/src/main/webapp/app/course/learning-paths/learning-paths.module.ts index 4d1a254a674f..8c37905813e9 100644 --- a/src/main/webapp/app/course/learning-paths/learning-paths.module.ts +++ b/src/main/webapp/app/course/learning-paths/learning-paths.module.ts @@ -3,10 +3,29 @@ import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { LearningPathManagementComponent } from 'app/course/learning-paths/learning-path-management/learning-path-management.component'; +import { NgxGraphModule } from '@swimlane/ngx-graph'; +import { ArtemisLectureUnitsModule } from 'app/overview/course-lectures/lecture-units.module'; +import { ArtemisCompetenciesModule } from 'app/course/competencies/competency.module'; +import { LearningPathProgressModalComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-modal.component'; +import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; +import { LearningPathProgressNavComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-nav.component'; +import { LearningPathGraphNodeComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph-node.component'; +import { CompetencyNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component'; +import { LectureUnitNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component'; +import { ExerciseNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component'; @NgModule({ - imports: [ArtemisSharedModule, FormsModule, ReactiveFormsModule, ArtemisSharedComponentModule], - declarations: [LearningPathManagementComponent], + imports: [ArtemisSharedModule, FormsModule, ReactiveFormsModule, ArtemisSharedComponentModule, NgxGraphModule, ArtemisLectureUnitsModule, ArtemisCompetenciesModule], + declarations: [ + LearningPathManagementComponent, + LearningPathProgressModalComponent, + LearningPathProgressNavComponent, + LearningPathGraphComponent, + LearningPathGraphNodeComponent, + CompetencyNodeDetailsComponent, + LectureUnitNodeDetailsComponent, + ExerciseNodeDetailsComponent, + ], exports: [], }) export class ArtemisLearningPathsModule {} 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 aee01ef000cd..8834651fbd21 100644 --- a/src/main/webapp/app/entities/competency/learning-path.model.ts +++ b/src/main/webapp/app/entities/competency/learning-path.model.ts @@ -2,6 +2,7 @@ import { BaseEntity } from 'app/shared/model/base-entity'; import { Course } from 'app/entities/course.model'; import { User, UserNameAndLoginDTO } from 'app/core/user/user.model'; import { Competency } from 'app/entities/competency.model'; +import { Edge, Node } from '@swimlane/ngx-graph'; export class LearningPath implements BaseEntity { public id?: number; @@ -18,3 +19,32 @@ export class LearningPathPageableSearchDTO { public user?: UserNameAndLoginDTO; public progress?: number; } + +export class NgxLearningPathDTO { + public nodes: NgxLearningPathNode[]; + public edges: NgxLearningPathEdge[]; +} + +export class NgxLearningPathNode implements Node { + public id: string; + public type?: NodeType; + public linkedResource?: number; + public linkedResourceParent?: number; + public completed?: boolean; + public label?: string; +} + +export class NgxLearningPathEdge implements Edge { + public id?: string; + public source: string; + public target: string; +} + +export enum NodeType { + COMPETENCY_START = 'COMPETENCY_START', + COMPETENCY_END = 'COMPETENCY_END', + MATCH_START = 'MATCH_START', + MATCH_END = 'MATCH_END', + EXERCISE = 'EXERCISE', + LECTURE_UNIT = 'LECTURE_UNIT', +} diff --git a/src/main/webapp/app/entities/lecture-unit/lectureUnit.model.ts b/src/main/webapp/app/entities/lecture-unit/lectureUnit.model.ts index 647d462e0cb7..48863fff879d 100644 --- a/src/main/webapp/app/entities/lecture-unit/lectureUnit.model.ts +++ b/src/main/webapp/app/entities/lecture-unit/lectureUnit.model.ts @@ -2,6 +2,8 @@ import { BaseEntity } from 'app/shared/model/base-entity'; import dayjs from 'dayjs/esm'; import { Lecture } from 'app/entities/lecture.model'; import { Competency } from 'app/entities/competency.model'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { faDownload, faLink, faQuestion, faScroll, faVideo } from '@fortawesome/free-solid-svg-icons'; // IMPORTANT NOTICE: The following strings have to be consistent with // the ones defined in LectureUnit.java @@ -13,6 +15,22 @@ export enum LectureUnitType { ONLINE = 'online', } +export const lectureUnitIcons = { + [LectureUnitType.ATTACHMENT]: faDownload, + [LectureUnitType.EXERCISE]: faQuestion, + [LectureUnitType.TEXT]: faScroll, + [LectureUnitType.VIDEO]: faVideo, + [LectureUnitType.ONLINE]: faLink, +}; + +export const lectureUnitTooltips = { + [LectureUnitType.ATTACHMENT]: 'artemisApp.attachmentUnit.tooltip', + [LectureUnitType.EXERCISE]: '', + [LectureUnitType.TEXT]: 'artemisApp.textUnit.tooltip', + [LectureUnitType.VIDEO]: 'artemisApp.videoUnit.tooltip', + [LectureUnitType.ONLINE]: 'artemisApp.onlineUnit.tooltip', +}; + export abstract class LectureUnit implements BaseEntity { public id?: number; public name?: string; @@ -28,3 +46,23 @@ export abstract class LectureUnit implements BaseEntity { this.type = type; } } + +export function getIcon(lectureUnitType: LectureUnitType): IconProp { + if (!lectureUnitType) { + return faQuestion as IconProp; + } + return lectureUnitIcons[lectureUnitType] as IconProp; +} + +export function getIconTooltip(lectureUnitType: LectureUnitType) { + if (!lectureUnitType) { + return ''; + } + return lectureUnitTooltips[lectureUnitType]; +} + +export class LectureUnitForLearningPathNodeDetailsDTO { + public id?: number; + public name?: string; + public type?: LectureUnitType; +} diff --git a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts index 7405159f676e..c16717ef0e0f 100644 --- a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts +++ b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts @@ -1,4 +1,4 @@ -import { LectureUnit, LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; +import { LectureUnit, LectureUnitForLearningPathNodeDetailsDTO, LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -138,4 +138,10 @@ export class LectureUnitService { return lectureUnit.releaseDate; } } + + getLectureUnitForLearningPathNodeDetails(lectureUnitId: number) { + return this.httpClient.get(`${this.resourceURL}/lecture-units/${lectureUnitId}/for-learning-path-node-details`, { + observe: 'response', + }); + } } diff --git a/src/main/webapp/app/shared/shared.module.ts b/src/main/webapp/app/shared/shared.module.ts index b63a45c1c99a..a7b2c03ec4a4 100644 --- a/src/main/webapp/app/shared/shared.module.ts +++ b/src/main/webapp/app/shared/shared.module.ts @@ -23,6 +23,7 @@ import { AssessmentWarningComponent } from 'app/assessment/assessment-warning/as import { JhiConnectionWarningComponent } from 'app/shared/connection-warning/connection-warning.component'; import { LoadingIndicatorContainerComponent } from 'app/shared/loading-indicator-container/loading-indicator-container.component'; import { CompetencySelectionComponent } from 'app/shared/competency-selection/competency-selection.component'; +import { StickyPopoverDirective } from 'app/shared/sticky-popover/sticky-popover.directive'; import { ConfirmEntityNameComponent } from 'app/shared/confirm-entity-name/confirm-entity-name.component'; @NgModule({ @@ -49,6 +50,7 @@ import { ConfirmEntityNameComponent } from 'app/shared/confirm-entity-name/confi ItemCountComponent, ConsistencyCheckComponent, AssessmentWarningComponent, + StickyPopoverDirective, ], exports: [ ArtemisSharedLibsModule, @@ -75,6 +77,7 @@ import { ConfirmEntityNameComponent } from 'app/shared/confirm-entity-name/confi ConsistencyCheckComponent, AssessmentWarningComponent, CompetencySelectionComponent, + StickyPopoverDirective, ], }) export class ArtemisSharedModule {} diff --git a/src/main/webapp/app/shared/sticky-popover/sticky-popover.directive.ts b/src/main/webapp/app/shared/sticky-popover/sticky-popover.directive.ts new file mode 100644 index 000000000000..4ec7db908407 --- /dev/null +++ b/src/main/webapp/app/shared/sticky-popover/sticky-popover.directive.ts @@ -0,0 +1,119 @@ +import { + ApplicationRef, + ChangeDetectorRef, + Directive, + ElementRef, + Inject, + Injector, + Input, + NgZone, + OnDestroy, + OnInit, + Renderer2, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { NgbPopover, NgbPopoverConfig } from '@ng-bootstrap/ng-bootstrap'; + +@Directive({ + selector: '[jhiStickyPopover]', +}) +export class StickyPopoverDirective extends NgbPopover implements OnInit, OnDestroy { + @Input() jhiStickyPopover: TemplateRef; + + popoverTitle: string; + + triggers: string; + container: string; + canClosePopover: boolean; + + private closeTimeout: any; + private clickInPopover: boolean = false; + + toggle(): void { + super.toggle(); + } + + isOpen(): boolean { + return super.isOpen(); + } + + constructor( + private _elRef: ElementRef, + private _render: Renderer2, + injector: Injector, + private viewContainerRef: ViewContainerRef, + config: NgbPopoverConfig, + ngZone: NgZone, + private changeRef: ChangeDetectorRef, + private applicationRef: ApplicationRef, + @Inject(DOCUMENT) _document: any, + ) { + super(_elRef, _render, injector, viewContainerRef, config, ngZone, _document, changeRef, applicationRef); + this.triggers = 'manual'; + this.popoverTitle = ''; + this.container = 'body'; + } + + ngOnInit(): void { + super.ngOnInit(); + this.ngbPopover = this.jhiStickyPopover; + + this._render.listen(this._elRef.nativeElement, 'pointerenter', () => { + this.canClosePopover = true; + clearTimeout(this.closeTimeout); + if (!this.isOpen()) { + this.open(); + } + }); + + this._render.listen(this._elRef.nativeElement, 'pointerleave', () => { + this.closeTimeout = setTimeout(() => { + if (this.canClosePopover) { + this.close(); + } + }, 100); + }); + + this._render.listen(this._elRef.nativeElement, 'click', () => { + this.close(); + }); + } + + ngOnDestroy(): void { + super.ngOnDestroy(); + } + + open() { + super.open(); + setTimeout(() => { + const popover = window.document.querySelector('.popover'); + this._render.listen(popover, 'mouseover', () => { + this.canClosePopover = false; + }); + + this._render.listen(popover, 'pointerleave', () => { + this.canClosePopover = true; + clearTimeout(this.closeTimeout); + this.closeTimeout = setTimeout(() => { + if (this.canClosePopover) { + this.close(); + } + }, 250); + }); + + this._render.listen(popover, 'click', () => { + this.clickInPopover = true; + }); + }, 0); + } + + close() { + if (this.clickInPopover) { + this.clickInPopover = false; + } else { + super.close(); + } + } +} diff --git a/src/main/webapp/i18n/de/competency.json b/src/main/webapp/i18n/de/competency.json index 62d59e2cd035..6543d4a798a8 100644 --- a/src/main/webapp/i18n/de/competency.json +++ b/src/main/webapp/i18n/de/competency.json @@ -169,6 +169,11 @@ "name": "Name", "login": "Login", "progress": "Fortschritt" + }, + "progressNav": { + "header": "Lernpfad", + "refresh": "Aktualisieren", + "center": "Zentrieren" } } } diff --git a/src/main/webapp/i18n/en/competency.json b/src/main/webapp/i18n/en/competency.json index fbc7c96a12aa..ba6dc5f20b2e 100644 --- a/src/main/webapp/i18n/en/competency.json +++ b/src/main/webapp/i18n/en/competency.json @@ -168,6 +168,11 @@ "name": "Name", "login": "Login", "progress": "Progress" + }, + "progressNav": { + "header": "Learning Path", + "refresh": "Refresh", + "center": "Center view" } } } diff --git a/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java index e478eb07629c..c2bab0712b0e 100644 --- a/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java @@ -402,4 +402,54 @@ void testUpdateLearningPathProgress() throws Exception { void testGetHealthStatusForCourse() throws Exception { request.get("/api/courses/" + course.getId() + "/learning-path-health", HttpStatus.OK, LearningPathHealthDTO.class); } + + @Test + @WithMockUser(username = STUDENT_OF_COURSE, roles = "USER") + void testGetLearningPathNgxGraphForLearningPathsDisabled() throws Exception { + course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); + final var student = userRepository.findOneByLogin(STUDENT_OF_COURSE).orElseThrow(); + final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), student.getId()); + course.setLearningPathsEnabled(false); + courseRepository.save(course); + request.get("/api/learning-path/" + learningPath.getId() + "/graph", HttpStatus.BAD_REQUEST, NgxLearningPathDTO.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student2", roles = "USER") + void testGetLearningPathNgxGraphForOtherStudent() throws Exception { + course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); + final var student = userRepository.findOneByLogin(STUDENT_OF_COURSE).orElseThrow(); + final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), student.getId()); + request.get("/api/learning-path/" + learningPath.getId() + "/graph", HttpStatus.FORBIDDEN, NgxLearningPathDTO.class); + } + + /** + * This only tests if the end point successfully retrieves the graph representation. The correctness of the response is tested in LearningPathServiceTest. + * + * @throws Exception the request failed + * @see de.tum.in.www1.artemis.service.LearningPathServiceTest + */ + @Test + @WithMockUser(username = STUDENT_OF_COURSE, roles = "USER") + void testGetLearningPathNgxGraphAsStudent() throws Exception { + course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); + final var student = userRepository.findOneByLogin(STUDENT_OF_COURSE).orElseThrow(); + final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), student.getId()); + request.get("/api/learning-path/" + learningPath.getId() + "/graph", HttpStatus.OK, NgxLearningPathDTO.class); + } + + /** + * This only tests if the end point successfully retrieves the graph representation. The correctness of the response is tested in LearningPathServiceTest. + * + * @throws Exception the request failed + * @see de.tum.in.www1.artemis.service.LearningPathServiceTest + */ + @Test + @WithMockUser(username = INSTRUCTOR_OF_COURSE, roles = "INSTRUCTOR") + void testGetLearningPathNgxGraphAsInstructor() throws Exception { + course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); + final var student = userRepository.findOneByLogin(STUDENT_OF_COURSE).orElseThrow(); + final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), student.getId()); + request.get("/api/learning-path/" + learningPath.getId() + "/graph", HttpStatus.OK, NgxLearningPathDTO.class); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java index dca6b87250a9..dbd732522b5e 100644 --- a/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java @@ -21,6 +21,7 @@ import de.tum.in.www1.artemis.domain.lecture.*; import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.user.UserUtilService; +import de.tum.in.www1.artemis.web.rest.dto.lectureunit.LectureUnitForLearningPathNodeDetailsDTO; class LectureUnitIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { @@ -275,4 +276,16 @@ void setLectureUnitCompletion_shouldReturnForbidden() throws Exception { HttpStatus.FORBIDDEN, null); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetLectureUnitForLearningPathNodeDetailsAsStudentOfCourse() throws Exception { + final var result = request.get("/api/lecture-units/" + textUnit.getId() + "/for-learning-path-node-details", HttpStatus.OK, LectureUnitForLearningPathNodeDetailsDTO.class); + assertThat(result).isEqualTo(LectureUnitForLearningPathNodeDetailsDTO.of(textUnit)); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student42", roles = "USER") + void testGetLectureUnitForLearningPathNodeDetailsAsStudentNotInCourse() throws Exception { + request.get("/api/lecture-units/" + textUnit.getId() + "/for-learning-path-node-details", HttpStatus.FORBIDDEN, LectureUnitForLearningPathNodeDetailsDTO.class); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java index f33149e252c9..0783a9e28b63 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java @@ -3,7 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.time.ZonedDateTime; -import java.util.HashSet; +import java.util.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -11,14 +11,24 @@ import org.springframework.beans.factory.annotation.Autowired; import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.competency.CompetencyUtilService; import de.tum.in.www1.artemis.competency.LearningPathUtilService; import de.tum.in.www1.artemis.course.CourseFactory; import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.domain.competency.Competency; +import de.tum.in.www1.artemis.domain.competency.CompetencyRelation; +import de.tum.in.www1.artemis.domain.competency.LearningPath; +import de.tum.in.www1.artemis.domain.lecture.LectureUnit; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; +import de.tum.in.www1.artemis.lecture.LectureUtilService; import de.tum.in.www1.artemis.repository.CourseRepository; +import de.tum.in.www1.artemis.repository.LearningPathRepository; import de.tum.in.www1.artemis.security.SecurityUtils; import de.tum.in.www1.artemis.user.UserUtilService; import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathHealthDTO; +import de.tum.in.www1.artemis.web.rest.dto.competency.NgxLearningPathDTO; class LearningPathServiceTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { @@ -39,6 +49,18 @@ class LearningPathServiceTest extends AbstractSpringIntegrationBambooBitbucketJi @Autowired private CourseRepository courseRepository; + @Autowired + private CompetencyUtilService competencyUtilService; + + @Autowired + private LectureUtilService lectureUtilService; + + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + + @Autowired + private LearningPathRepository learningPathRepository; + private Course course; @BeforeEach @@ -84,4 +106,192 @@ void testHealthStatusMissing() { assertThat(healthStatus.missingLearningPaths()).isEqualTo(1); } } + + @Nested + class GenerateNgxRepresentationBaseTest { + + @Test + void testEmptyLearningPath() { + NgxLearningPathDTO expected = new NgxLearningPathDTO(Set.of(), Set.of()); + generateAndAssert(expected); + } + + @Test + void testEmptyCompetency() { + final var competency = competencyUtilService.createCompetency(course); + final var startNodeId = LearningPathService.getCompetencyStartNodeId(competency.getId()); + final var endNodeId = LearningPathService.getCompetencyEndNodeId(competency.getId()); + Set expectedNodes = getExpectedNodesOfEmptyCompetency(competency); + Set expectedEdges = Set.of(new NgxLearningPathDTO.Edge(LearningPathService.getDirectEdgeId(competency.getId()), startNodeId, endNodeId)); + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + + @Test + void testCompetencyWithLectureUnitAndExercise() { + var competency = competencyUtilService.createCompetency(course); + var lecture = lectureUtilService.createLecture(course, ZonedDateTime.now()); + final var lectureUnit = lectureUtilService.createTextUnit(); + lectureUtilService.addLectureUnitsToLecture(lecture, List.of(lectureUnit)); + competencyUtilService.linkLectureUnitToCompetency(competency, lectureUnit); + final var exercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course, false); + competencyUtilService.linkExerciseToCompetency(competency, exercise); + final var startNodeId = LearningPathService.getCompetencyStartNodeId(competency.getId()); + final var endNodeId = LearningPathService.getCompetencyEndNodeId(competency.getId()); + Set expectedNodes = getExpectedNodesOfEmptyCompetency(competency); + expectedNodes.add(getNodeForLectureUnit(competency, lectureUnit)); + expectedNodes.add(getNodeForExercise(competency, exercise)); + Set expectedEdges = Set.of( + new NgxLearningPathDTO.Edge(LearningPathService.getLectureUnitInEdgeId(competency.getId(), lectureUnit.getId()), startNodeId, + LearningPathService.getLectureUnitNodeId(competency.getId(), lectureUnit.getId())), + new NgxLearningPathDTO.Edge(LearningPathService.getLectureUnitOutEdgeId(competency.getId(), lectureUnit.getId()), + LearningPathService.getLectureUnitNodeId(competency.getId(), lectureUnit.getId()), endNodeId), + new NgxLearningPathDTO.Edge(LearningPathService.getExerciseInEdgeId(competency.getId(), exercise.getId()), startNodeId, + LearningPathService.getExerciseNodeId(competency.getId(), exercise.getId())), + new NgxLearningPathDTO.Edge(LearningPathService.getExerciseOutEdgeId(competency.getId(), exercise.getId()), + LearningPathService.getExerciseNodeId(competency.getId(), exercise.getId()), endNodeId)); + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + + @Test + void testMultipleCompetencies() { + Competency[] competencies = { competencyUtilService.createCompetency(course), competencyUtilService.createCompetency(course), + competencyUtilService.createCompetency(course) }; + String[] startNodeIds = Arrays.stream(competencies).map(Competency::getId).map(LearningPathService::getCompetencyStartNodeId).toArray(String[]::new); + String[] endNodeIds = Arrays.stream(competencies).map(Competency::getId).map(LearningPathService::getCompetencyEndNodeId).toArray(String[]::new); + Set expectedNodes = new HashSet<>(); + Set expectedEdges = new HashSet<>(); + for (int i = 0; i < competencies.length; i++) { + expectedNodes.addAll(getExpectedNodesOfEmptyCompetency(competencies[i])); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getDirectEdgeId(competencies[i].getId()), startNodeIds[i], endNodeIds[i])); + } + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + } + + @Nested + class GenerateNgxRepresentationRelationTest { + + private Competency competency1; + + private Competency competency2; + + private Set expectedNodes; + + Set expectedEdges; + + @BeforeEach + void setup() { + competency1 = competencyUtilService.createCompetency(course); + competency2 = competencyUtilService.createCompetency(course); + expectedNodes = new HashSet<>(); + expectedEdges = new HashSet<>(); + addExpectedComponentsForEmptyCompetencies(competency1, competency2); + } + + void testSimpleRelation(CompetencyRelation.RelationType type) { + competencyUtilService.addRelation(competency1, type, competency2); + final var sourceNodeId = LearningPathService.getCompetencyEndNodeId(competency2.getId()); + final var targetNodeId = LearningPathService.getCompetencyStartNodeId(competency1.getId()); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getRelationEdgeId(sourceNodeId, targetNodeId), sourceNodeId, targetNodeId)); + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + + @Test + void testSingleRelates() { + testSimpleRelation(CompetencyRelation.RelationType.RELATES); + } + + @Test + void testSingleAssumes() { + testSimpleRelation(CompetencyRelation.RelationType.ASSUMES); + } + + @Test + void testSingleExtends() { + testSimpleRelation(CompetencyRelation.RelationType.EXTENDS); + } + + @Test + void testSingleMatches() { + competencyUtilService.addRelation(competency1, CompetencyRelation.RelationType.MATCHES, competency2); + expectedNodes.add(new NgxLearningPathDTO.Node(LearningPathService.getMatchingClusterStartNodeId(0), NgxLearningPathDTO.NodeType.MATCH_START, null, "")); + expectedNodes.add(new NgxLearningPathDTO.Node(LearningPathService.getMatchingClusterEndNodeId(0), NgxLearningPathDTO.NodeType.MATCH_END, null, "")); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getInEdgeId(competency1.getId()), LearningPathService.getMatchingClusterStartNodeId(0), + LearningPathService.getCompetencyStartNodeId(competency1.getId()))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getOutEdgeId(competency1.getId()), LearningPathService.getCompetencyEndNodeId(competency1.getId()), + LearningPathService.getMatchingClusterEndNodeId(0))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getInEdgeId(competency2.getId()), LearningPathService.getMatchingClusterStartNodeId(0), + LearningPathService.getCompetencyStartNodeId(competency2.getId()))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getOutEdgeId(competency2.getId()), LearningPathService.getCompetencyEndNodeId(competency2.getId()), + LearningPathService.getMatchingClusterEndNodeId(0))); + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + + @Test + void testMatchesTransitive() { + var competency3 = competencyUtilService.createCompetency(course); + addExpectedComponentsForEmptyCompetencies(competency3); + + competencyUtilService.addRelation(competency1, CompetencyRelation.RelationType.MATCHES, competency2); + competencyUtilService.addRelation(competency2, CompetencyRelation.RelationType.MATCHES, competency3); + expectedNodes.add(new NgxLearningPathDTO.Node(LearningPathService.getMatchingClusterStartNodeId(0), NgxLearningPathDTO.NodeType.MATCH_START, null, "")); + expectedNodes.add(new NgxLearningPathDTO.Node(LearningPathService.getMatchingClusterEndNodeId(0), NgxLearningPathDTO.NodeType.MATCH_END, null, "")); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getInEdgeId(competency1.getId()), LearningPathService.getMatchingClusterStartNodeId(0), + LearningPathService.getCompetencyStartNodeId(competency1.getId()))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getOutEdgeId(competency1.getId()), LearningPathService.getCompetencyEndNodeId(competency1.getId()), + LearningPathService.getMatchingClusterEndNodeId(0))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getInEdgeId(competency2.getId()), LearningPathService.getMatchingClusterStartNodeId(0), + LearningPathService.getCompetencyStartNodeId(competency2.getId()))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getOutEdgeId(competency2.getId()), LearningPathService.getCompetencyEndNodeId(competency2.getId()), + LearningPathService.getMatchingClusterEndNodeId(0))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getInEdgeId(competency3.getId()), LearningPathService.getMatchingClusterStartNodeId(0), + LearningPathService.getCompetencyStartNodeId(competency3.getId()))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getOutEdgeId(competency3.getId()), LearningPathService.getCompetencyEndNodeId(competency3.getId()), + LearningPathService.getMatchingClusterEndNodeId(0))); + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + + private void addExpectedComponentsForEmptyCompetencies(Competency... competencies) { + for (var competency : competencies) { + expectedNodes.addAll(getExpectedNodesOfEmptyCompetency(competency)); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getDirectEdgeId(competency.getId()), + LearningPathService.getCompetencyStartNodeId(competency.getId()), LearningPathService.getCompetencyEndNodeId(competency.getId()))); + } + } + } + + private void generateAndAssert(NgxLearningPathDTO expected) { + LearningPath learningPath = learningPathUtilService.createLearningPathInCourse(course); + learningPath = learningPathRepository.findWithEagerCompetenciesAndLearningObjectsAndCompletedUsersByIdElseThrow(learningPath.getId()); + NgxLearningPathDTO actual = learningPathService.generateNgxGraphRepresentation(learningPath); + assertThat(actual).isNotNull(); + assertNgxRepEquals(actual, expected); + } + + private void assertNgxRepEquals(NgxLearningPathDTO was, NgxLearningPathDTO expected) { + assertThat(was.nodes()).as("correct nodes").containsExactlyInAnyOrderElementsOf(expected.nodes()); + assertThat(was.edges()).as("correct edges").containsExactlyInAnyOrderElementsOf(expected.edges()); + } + + private static Set getExpectedNodesOfEmptyCompetency(Competency competency) { + return new HashSet<>(Set.of( + new NgxLearningPathDTO.Node(LearningPathService.getCompetencyStartNodeId(competency.getId()), NgxLearningPathDTO.NodeType.COMPETENCY_START, competency.getId(), ""), + new NgxLearningPathDTO.Node(LearningPathService.getCompetencyEndNodeId(competency.getId()), NgxLearningPathDTO.NodeType.COMPETENCY_END, competency.getId(), ""))); + } + + private static NgxLearningPathDTO.Node getNodeForLectureUnit(Competency competency, LectureUnit lectureUnit) { + return new NgxLearningPathDTO.Node(LearningPathService.getLectureUnitNodeId(competency.getId(), lectureUnit.getId()), NgxLearningPathDTO.NodeType.LECTURE_UNIT, + lectureUnit.getId(), lectureUnit.getLecture().getId(), false, lectureUnit.getName()); + } + + private static NgxLearningPathDTO.Node getNodeForExercise(Competency competency, Exercise exercise) { + return new NgxLearningPathDTO.Node(LearningPathService.getExerciseNodeId(competency.getId(), exercise.getId()), NgxLearningPathDTO.NodeType.EXERCISE, exercise.getId(), + exercise.getTitle()); + } } diff --git a/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph-node.component.spec.ts b/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph-node.component.spec.ts new file mode 100644 index 000000000000..8371428d66ed --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph-node.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../test.module'; +import { MockDirective } from 'ng-mocks'; +import { By } from '@angular/platform-browser'; +import { LearningPathGraphNodeComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph-node.component'; +import { NgxLearningPathNode, NodeType } from 'app/entities/competency/learning-path.model'; +import { StickyPopoverDirective } from 'app/shared/sticky-popover/sticky-popover.directive'; + +describe('LearningPathGraphNodeComponent', () => { + let fixture: ComponentFixture; + let comp: LearningPathGraphNodeComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + declarations: [LearningPathGraphNodeComponent, MockDirective(StickyPopoverDirective)], + providers: [], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(LearningPathGraphNodeComponent); + comp = fixture.componentInstance; + }); + }); + + it.each([NodeType.EXERCISE, NodeType.LECTURE_UNIT])('should display correct icon for completed learning object', (type: NodeType) => { + comp.node = { id: '1', type: type, completed: true } as NgxLearningPathNode; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('#completed')).nativeElement).toBeTruthy(); + }); + + it.each([NodeType.EXERCISE, NodeType.LECTURE_UNIT])('should display correct icon for not completed learning object', (type: NodeType) => { + comp.node = { id: '1', type: type, completed: false } as NgxLearningPathNode; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('#not-completed')).nativeElement).toBeTruthy(); + }); + + it.each([NodeType.COMPETENCY_START, NodeType.COMPETENCY_END, NodeType.COMPETENCY_START, NodeType.COMPETENCY_END])( + 'should display correct icon for generic node', + (type: NodeType) => { + comp.node = { id: '1', type: type } as NgxLearningPathNode; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('#generic')).nativeElement).toBeTruthy(); + }, + ); +}); diff --git a/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph.component.spec.ts b/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph.component.spec.ts new file mode 100644 index 000000000000..42f707efb83c --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../test.module'; +import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; +import { LearningPathService } from 'app/course/learning-paths/learning-path.service'; + +describe('LearningPathGraphComponent', () => { + let fixture: ComponentFixture; + let comp: LearningPathGraphComponent; + let learningPathService: LearningPathService; + let getLearningPathNgxGraphStub: jest.SpyInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + declarations: [LearningPathGraphComponent], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(LearningPathGraphComponent); + comp = fixture.componentInstance; + learningPathService = TestBed.inject(LearningPathService); + getLearningPathNgxGraphStub = jest.spyOn(learningPathService, 'getLearningPathNgxGraph'); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should load learning path from service', () => { + comp.learningPathId = 1; + fixture.detectChanges(); + expect(getLearningPathNgxGraphStub).toHaveBeenCalledOnce(); + expect(getLearningPathNgxGraphStub).toHaveBeenCalledWith(1); + }); + + it('should update, center, and zoom to fit on resize', () => { + const updateStub = jest.spyOn(comp.update$, 'next'); + const centerStub = jest.spyOn(comp.center$, 'next'); + const zoomToFitStub = jest.spyOn(comp.zoomToFit$, 'next'); + fixture.detectChanges(); + comp.onResize(); + expect(updateStub).toHaveBeenCalledOnce(); + expect(updateStub).toHaveBeenCalledWith(true); + expect(centerStub).toHaveBeenCalledOnce(); + expect(centerStub).toHaveBeenCalledWith(true); + expect(zoomToFitStub).toHaveBeenCalledOnce(); + expect(zoomToFitStub).toHaveBeenCalledWith(true); + }); + + it('should zoom to fit and center on resize', () => { + const zoomToFitStub = jest.spyOn(comp.zoomToFit$, 'next'); + const centerStub = jest.spyOn(comp.center$, 'next'); + fixture.detectChanges(); + comp.onCenterView(); + expect(zoomToFitStub).toHaveBeenCalledOnce(); + expect(zoomToFitStub).toHaveBeenCalledWith(true); + expect(centerStub).toHaveBeenCalledOnce(); + expect(centerStub).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/graph/node-details/competency-node-details.component.spec.ts b/src/test/javascript/spec/component/learning-paths/graph/node-details/competency-node-details.component.spec.ts new file mode 100644 index 000000000000..f68f77d41183 --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/graph/node-details/competency-node-details.component.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../../test.module'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; +import { HttpResponse } from '@angular/common/http'; +import { CompetencyNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component'; +import { Competency, CompetencyProgress, CompetencyTaxonomy } from 'app/entities/competency.model'; +import { CompetencyService } from 'app/course/competencies/competency.service'; +import { CompetencyRingsComponent } from 'app/course/competencies/competency-rings/competency-rings.component'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltipMocksModule } from '../../../../helpers/mocks/directive/ngbTooltipMocks.module'; + +describe('CompetencyNodeDetailsComponent', () => { + let fixture: ComponentFixture; + let comp: CompetencyNodeDetailsComponent; + let competencyService: CompetencyService; + let findByIdStub: jest.SpyInstance; + let competency: Competency; + let competencyProgress: CompetencyProgress; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, NgbTooltipMocksModule], + declarations: [CompetencyNodeDetailsComponent, MockComponent(CompetencyRingsComponent), MockPipe(ArtemisTranslatePipe)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(CompetencyNodeDetailsComponent); + comp = fixture.componentInstance; + competency = new Competency(); + competency.id = 2; + competency.title = 'Some arbitrary title'; + competency.description = 'Some description'; + competency.taxonomy = CompetencyTaxonomy.ANALYZE; + competency.masteryThreshold = 50; + competencyProgress = new CompetencyProgress(); + competencyProgress.progress = 80; + competencyProgress.confidence = 70; + competency.userProgress = [competencyProgress]; + + competencyService = TestBed.inject(CompetencyService); + findByIdStub = jest.spyOn(competencyService, 'findById').mockReturnValue(of(new HttpResponse({ body: competency }))); + comp.courseId = 1; + comp.competencyId = competency.id; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should load competency on init if not present', () => { + fixture.detectChanges(); + expect(findByIdStub).toHaveBeenCalledOnce(); + expect(findByIdStub).toHaveBeenCalledWith(competency.id, 1); + expect(comp.competency).toEqual(competency); + expect(comp.competencyProgress).toEqual(competencyProgress); + }); + + it('should not load competency on init if already present', () => { + comp.competency = competency; + comp.competencyProgress = competencyProgress; + fixture.detectChanges(); + expect(findByIdStub).not.toHaveBeenCalled(); + }); + + it('should default progress to zero if empty', () => { + competency.userProgress = undefined; + fixture.detectChanges(); + expect(findByIdStub).toHaveBeenCalledOnce(); + expect(findByIdStub).toHaveBeenCalledWith(competency.id, 1); + expect(comp.competency).toEqual(competency); + expect(comp.competencyProgress).toEqual({ confidence: 0, progress: 0 } as CompetencyProgress); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/graph/node-details/exercise-node-details.component.spec.ts b/src/test/javascript/spec/component/learning-paths/graph/node-details/exercise-node-details.component.spec.ts new file mode 100644 index 000000000000..7469f93f790d --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/graph/node-details/exercise-node-details.component.spec.ts @@ -0,0 +1,55 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../../test.module'; +import { MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; +import { HttpResponse } from '@angular/common/http'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltipMocksModule } from '../../../../helpers/mocks/directive/ngbTooltipMocks.module'; +import { ExerciseNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { Exercise } from 'app/entities/exercise.model'; +import { TextExercise } from 'app/entities/text-exercise.model'; + +describe('ExerciseNodeDetailsComponent', () => { + let fixture: ComponentFixture; + let comp: ExerciseNodeDetailsComponent; + let exerciseService: ExerciseService; + let findStub: jest.SpyInstance; + let exercise: Exercise; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, NgbTooltipMocksModule], + declarations: [ExerciseNodeDetailsComponent, MockPipe(ArtemisTranslatePipe)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(ExerciseNodeDetailsComponent); + comp = fixture.componentInstance; + exercise = new TextExercise(undefined, undefined); + exercise.id = 1; + exercise.title = 'Some arbitrary title'; + + exerciseService = TestBed.inject(ExerciseService); + findStub = jest.spyOn(exerciseService, 'find').mockReturnValue(of(new HttpResponse({ body: exercise }))); + comp.exerciseId = exercise.id; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should load exercise on init if not present', () => { + fixture.detectChanges(); + expect(findStub).toHaveBeenCalledOnce(); + expect(findStub).toHaveBeenCalledWith(exercise.id); + expect(comp.exercise).toEqual(exercise); + }); + + it('should not load exercise on init if already present', () => { + comp.exercise = exercise; + fixture.detectChanges(); + expect(findStub).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/graph/node-details/lecture-unit-node-details.component.spec.ts b/src/test/javascript/spec/component/learning-paths/graph/node-details/lecture-unit-node-details.component.spec.ts new file mode 100644 index 000000000000..13f47328ff5d --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/graph/node-details/lecture-unit-node-details.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../../test.module'; +import { MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; +import { HttpResponse } from '@angular/common/http'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltipMocksModule } from '../../../../helpers/mocks/directive/ngbTooltipMocks.module'; +import { LectureUnitNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component'; +import { LectureUnitForLearningPathNodeDetailsDTO, LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; +import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; + +describe('LectureUnitNodeDetailsComponent', () => { + let fixture: ComponentFixture; + let comp: LectureUnitNodeDetailsComponent; + let lectureUnitService: LectureUnitService; + let getLectureUnitForLearningPathNodeDetailsStub: jest.SpyInstance; + let lectureUnit: LectureUnitForLearningPathNodeDetailsDTO; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, NgbTooltipMocksModule], + declarations: [LectureUnitNodeDetailsComponent, MockPipe(ArtemisTranslatePipe)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(LectureUnitNodeDetailsComponent); + comp = fixture.componentInstance; + lectureUnit = new LectureUnitForLearningPathNodeDetailsDTO(); + lectureUnit.id = 1; + lectureUnit.name = 'Some arbitrary name'; + lectureUnit.type = LectureUnitType.TEXT; + + lectureUnitService = TestBed.inject(LectureUnitService); + getLectureUnitForLearningPathNodeDetailsStub = jest + .spyOn(lectureUnitService, 'getLectureUnitForLearningPathNodeDetails') + .mockReturnValue(of(new HttpResponse({ body: lectureUnit }))); + comp.lectureUnitId = lectureUnit.id; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should load lecture unit on init if not present', () => { + fixture.detectChanges(); + expect(getLectureUnitForLearningPathNodeDetailsStub).toHaveBeenCalledOnce(); + expect(getLectureUnitForLearningPathNodeDetailsStub).toHaveBeenCalledWith(lectureUnit.id); + expect(comp.lectureUnit).toEqual(lectureUnit); + }); + + it('should not load lecture unit on init if already present', () => { + comp.lectureUnit = lectureUnit; + fixture.detectChanges(); + expect(getLectureUnitForLearningPathNodeDetailsStub).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-modal.component.spec.ts b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-modal.component.spec.ts new file mode 100644 index 000000000000..f0fc95d2572b --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-modal.component.spec.ts @@ -0,0 +1,47 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../test.module'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { MockComponent } from 'ng-mocks'; +import { LearningPathProgressModalComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-modal.component'; +import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; +import { By } from '@angular/platform-browser'; +import { LearningPathProgressNavComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-nav.component'; +import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; + +describe('LearningPathProgressModalComponent', () => { + let fixture: ComponentFixture; + let comp: LearningPathProgressModalComponent; + let activeModal: NgbActiveModal; + let closeStub: jest.SpyInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, MockComponent(LearningPathGraphComponent), MockComponent(LearningPathProgressNavComponent)], + declarations: [LearningPathProgressModalComponent], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(LearningPathProgressModalComponent); + comp = fixture.componentInstance; + activeModal = TestBed.inject(NgbActiveModal); + closeStub = jest.spyOn(activeModal, 'close'); + fixture.detectChanges(); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should display learning path graph if learning path is present', () => { + comp.courseId = 2; + comp.learningPath = { id: 1 } as LearningPathPageableSearchDTO; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.graph')).nativeElement).toBeTruthy(); + }); + + it('should correctly close modal', () => { + comp.close(); + expect(closeStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-nav.component.spec.ts b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-nav.component.spec.ts new file mode 100644 index 000000000000..f579a9163537 --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-nav.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../test.module'; +import { By } from '@angular/platform-browser'; +import { LearningPathProgressNavComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-nav.component'; +import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; +import { UserNameAndLoginDTO } from 'app/core/user/user.model'; +import { MockPipe } from 'ng-mocks'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltipMocksModule } from '../../../helpers/mocks/directive/ngbTooltipMocks.module'; + +describe('LearningPathProgressNavComponent', () => { + let fixture: ComponentFixture; + let comp: LearningPathProgressNavComponent; + let onRefreshStub: jest.SpyInstance; + let onCenterViewStub: jest.SpyInstance; + let onCloseStub: jest.SpyInstance; + let learningPath: LearningPathPageableSearchDTO; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, NgbTooltipMocksModule], + declarations: [LearningPathProgressNavComponent, MockPipe(ArtemisTranslatePipe)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(LearningPathProgressNavComponent); + comp = fixture.componentInstance; + onRefreshStub = jest.spyOn(comp.onRefresh, 'emit'); + onCenterViewStub = jest.spyOn(comp.onCenterView, 'emit'); + onCloseStub = jest.spyOn(comp.onClose, 'emit'); + learningPath = new LearningPathPageableSearchDTO(); + learningPath.user = new UserNameAndLoginDTO(); + learningPath.user.name = 'some arbitrary name'; + learningPath.user.login = 'somearbitrarylogin'; + comp.learningPath = learningPath; + fixture.detectChanges(); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should create', () => { + expect(fixture).toBeTruthy(); + expect(comp).toBeTruthy(); + }); + + it('should emit refresh on click', () => { + const button = fixture.debugElement.query(By.css('#refresh-button')); + expect(button).not.toBeNull(); + + button.nativeElement.click(); + expect(onRefreshStub).toHaveBeenCalledOnce(); + }); + + it('should emit center view on click', () => { + const button = fixture.debugElement.query(By.css('#center-button')); + expect(button).not.toBeNull(); + + button.nativeElement.click(); + expect(onCenterViewStub).toHaveBeenCalledOnce(); + }); + + it('should emit close on click', () => { + const button = fixture.debugElement.query(By.css('#close-button')); + expect(button).not.toBeNull(); + + button.nativeElement.click(); + expect(onCloseStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts b/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts index 2a69069e42da..56bd1c1bd962 100644 --- a/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts +++ b/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts @@ -92,4 +92,9 @@ describe('LectureUnitService', () => { expect(service.getLectureUnitReleaseDate(textUnit)).toEqual(textUnit.releaseDate); expect(service.getLectureUnitReleaseDate(videoUnit)).toEqual(videoUnit.releaseDate); }); + + it('should send a request to the server to get ngx representation of learning path', fakeAsync(() => { + service.getLectureUnitForLearningPathNodeDetails(1).subscribe(); + httpMock.expectOne({ method: 'GET', url: 'api/lecture-units/1/for-learning-path-node-details' }); + })); }); diff --git a/src/test/javascript/spec/directive/sticky-popover.directive.spec.ts b/src/test/javascript/spec/directive/sticky-popover.directive.spec.ts new file mode 100644 index 000000000000..fb62f9d6c7af --- /dev/null +++ b/src/test/javascript/spec/directive/sticky-popover.directive.spec.ts @@ -0,0 +1,74 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { Component, DebugElement } from '@angular/core'; +import { ArtemisTestModule } from '../test.module'; +import { By } from '@angular/platform-browser'; +import { StickyPopoverDirective } from 'app/shared/sticky-popover/sticky-popover.directive'; + +@Component({ + template: '
some content', +}) +class StickyPopoverComponent { + pattern: string; +} + +describe('StickyPopoverDirective', () => { + let fixture: ComponentFixture; + let debugDirective: DebugElement; + let directive: StickyPopoverDirective; + let openStub: jest.SpyInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + declarations: [StickyPopoverDirective, StickyPopoverComponent], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(StickyPopoverComponent); + debugDirective = fixture.debugElement.query(By.directive(StickyPopoverDirective)); + directive = debugDirective.injector.get(StickyPopoverDirective); + openStub = jest.spyOn(directive, 'open'); + fixture.detectChanges(); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should open on hover', fakeAsync(() => { + fixture.whenStable(); + const div = fixture.debugElement.query(By.css('div')); + expect(div).not.toBeNull(); + div.nativeElement.dispatchEvent(new MouseEvent('pointerenter')); + tick(10); + expect(openStub).toHaveBeenCalledOnce(); + expect(directive.isOpen()).toBeTruthy(); + const span = fixture.debugElement.query(By.css('span')); + expect(span).not.toBeNull(); + })); + + it('should display content on hover', fakeAsync(() => { + fixture.whenStable(); + const div = fixture.debugElement.query(By.css('div')); + expect(div).not.toBeNull(); + div.nativeElement.dispatchEvent(new MouseEvent('pointerenter')); + tick(10); + const span = fixture.debugElement.query(By.css('span')); + expect(span).not.toBeNull(); + })); + + it('should close on leave', fakeAsync(() => { + fixture.whenStable(); + const div = fixture.debugElement.query(By.css('div')); + expect(div).not.toBeNull(); + div.nativeElement.dispatchEvent(new MouseEvent('pointerenter')); + tick(10); + let span = fixture.debugElement.query(By.css('span')); + expect(span).not.toBeNull(); + div.nativeElement.dispatchEvent(new MouseEvent('pointerleave')); + tick(100); + span = fixture.debugElement.query(By.css('span')); + expect(span).toBeNull(); + })); +}); diff --git a/src/test/javascript/spec/service/learning-path.service.spec.ts b/src/test/javascript/spec/service/learning-path.service.spec.ts index cbd2601a8266..2375b8c0d377 100644 --- a/src/test/javascript/spec/service/learning-path.service.spec.ts +++ b/src/test/javascript/spec/service/learning-path.service.spec.ts @@ -45,4 +45,10 @@ describe('LearningPathService', () => { expect(getStub).toHaveBeenCalledOnce(); expect(getStub).toHaveBeenCalledWith('api/courses/1/learning-path-health', { observe: 'response' }); }); + + it('should send a request to the server to get ngx representation of learning path', () => { + learningPathService.getLearningPathNgxGraph(1).subscribe(); + expect(getStub).toHaveBeenCalledOnce(); + expect(getStub).toHaveBeenCalledWith('api/learning-path/1/graph', { observe: 'response' }); + }); }); From ccb2ddbb2e1e60958bda061ea5de27d2b9f73aa9 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Thu, 21 Sep 2023 23:41:10 +0200 Subject: [PATCH 03/17] Development: Fix a dependency issues with Bamboo specs --- .../Artemis__Server__LocalVC___LocalCI__IRIS_.xml | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.idea/runConfigurations/Artemis__Server__LocalVC___LocalCI__IRIS_.xml b/.idea/runConfigurations/Artemis__Server__LocalVC___LocalCI__IRIS_.xml index 9e1aa86981de..00e4e1f2b43d 100644 --- a/.idea/runConfigurations/Artemis__Server__LocalVC___LocalCI__IRIS_.xml +++ b/.idea/runConfigurations/Artemis__Server__LocalVC___LocalCI__IRIS_.xml @@ -2,7 +2,7 @@
style="max-height: 44%" > - {{ 'artemisApp.quizExercise.home.createLabel' | artemisTranslate }} + - - + - + - +
diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-create-buttons.component.html b/src/main/webapp/app/exercises/programming/manage/programming-exercise-create-buttons.component.html index 91bee476f954..bfd15c7e0002 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-create-buttons.component.html +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-create-buttons.component.html @@ -8,6 +8,7 @@ > Create Programming Exercise +
diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-create-buttons.component.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-create-buttons.component.ts index c1fa37c0a30c..5ec55e54f894 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-create-buttons.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-create-buttons.component.ts @@ -1,7 +1,7 @@ import { Component, Input } from '@angular/core'; import { Course } from 'app/entities/course.model'; import { FeatureToggle } from 'app/shared/feature-toggle/feature-toggle.service'; -import { faFileImport, faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faFileImport, faKeyboard, faPlus } from '@fortawesome/free-solid-svg-icons'; import { ExerciseImportWrapperComponent } from 'app/exercises/shared/import/exercise-import-wrapper/exercise-import-wrapper.component'; import { ExerciseType } from 'app/entities/exercise.model'; import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; @@ -20,6 +20,7 @@ export class ProgrammingExerciseCreateButtonsComponent { faPlus = faPlus; faFileImport = faFileImport; + faKeyboard = faKeyboard; constructor( private router: Router, diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-create-buttons.component.html b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-create-buttons.component.html index b6057241b807..fbc66fcfd615 100644 --- a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-create-buttons.component.html +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-create-buttons.component.html @@ -7,14 +7,17 @@ > Create new Quiz + diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-create-buttons.component.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-create-buttons.component.ts index 7f35097392ab..80924d7e8254 100644 --- a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-create-buttons.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-create-buttons.component.ts @@ -1,6 +1,6 @@ import { Component, Input } from '@angular/core'; import { Course } from 'app/entities/course.model'; -import { faFileExport, faFileImport, faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faCheckDouble, faFileExport, faFileImport, faPlus } from '@fortawesome/free-solid-svg-icons'; import { ExerciseImportWrapperComponent } from 'app/exercises/shared/import/exercise-import-wrapper/exercise-import-wrapper.component'; import { ExerciseType } from 'app/entities/exercise.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @@ -19,6 +19,7 @@ export class QuizExerciseCreateButtonsComponent { faPlus = faPlus; faFileImport = faFileImport; faFileExport = faFileExport; + faCheckDouble = faCheckDouble; constructor( private router: Router, diff --git a/src/main/webapp/app/exercises/shared/manage/exercise-create-buttons.component.html b/src/main/webapp/app/exercises/shared/manage/exercise-create-buttons.component.html index 4d616bbed07b..5cc06d26350f 100644 --- a/src/main/webapp/app/exercises/shared/manage/exercise-create-buttons.component.html +++ b/src/main/webapp/app/exercises/shared/manage/exercise-create-buttons.component.html @@ -7,9 +7,11 @@ > Create new Exercise + diff --git a/src/main/webapp/app/exercises/shared/manage/exercise-create-buttons.component.ts b/src/main/webapp/app/exercises/shared/manage/exercise-create-buttons.component.ts index 8a738a285ee1..f24d1d507c9d 100644 --- a/src/main/webapp/app/exercises/shared/manage/exercise-create-buttons.component.ts +++ b/src/main/webapp/app/exercises/shared/manage/exercise-create-buttons.component.ts @@ -2,6 +2,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { Course } from 'app/entities/course.model'; import { faFileImport, faPlus } from '@fortawesome/free-solid-svg-icons'; import { ExerciseImportWrapperComponent } from 'app/exercises/shared/import/exercise-import-wrapper/exercise-import-wrapper.component'; +import { getIcon } from 'app/entities/exercise.model'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { Router } from '@angular/router'; @@ -19,6 +20,8 @@ export class ExerciseCreateButtonsComponent implements OnInit { faPlus = faPlus; faFileImport = faFileImport; + getExerciseTypeIcon = getIcon; + constructor( private router: Router, private modalService: NgbModal, diff --git a/src/test/javascript/spec/component/exam/manage/exam-exercise-import.component.spec.ts b/src/test/javascript/spec/component/exam/manage/exam-exercise-import.component.spec.ts index ce6488144644..31aa300beba3 100644 --- a/src/test/javascript/spec/component/exam/manage/exam-exercise-import.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/exam-exercise-import.component.spec.ts @@ -293,11 +293,11 @@ describe('Exam Exercise Import Component', () => { }); it('should correctly return the Exercise Icon', () => { - expect(component.getExerciseIcon(modelingExercise)).toEqual(faProjectDiagram); - expect(component.getExerciseIcon(textExercise)).toEqual(faFont); - expect(component.getExerciseIcon(programmingExercise)).toEqual(faKeyboard); - expect(component.getExerciseIcon(quizExercise)).toEqual(faCheckDouble); - expect(component.getExerciseIcon(fileUploadExercise)).toEqual(faFileUpload); + expect(component.getExerciseIcon(modelingExercise.type)).toEqual(faProjectDiagram); + expect(component.getExerciseIcon(textExercise.type)).toEqual(faFont); + expect(component.getExerciseIcon(programmingExercise.type)).toEqual(faKeyboard); + expect(component.getExerciseIcon(quizExercise.type)).toEqual(faCheckDouble); + expect(component.getExerciseIcon(fileUploadExercise.type)).toEqual(faFileUpload); }); describe('Programming exercise import validation', () => { From 819d4ca9f84a26d551e5907d2db427e16e847742 Mon Sep 17 00:00:00 2001 From: Tobias Lippert <84102468+tobias-lippert@users.noreply.github.com> Date: Fri, 22 Sep 2023 16:04:19 +0200 Subject: [PATCH 13/17] Development: Document the exam timeline (#7201) --- .../exams/instructor/buttons/exam_timeline.png | Bin 0 -> 1121 bytes .../exams/instructor/exam_timeline_example.png | Bin 0 -> 56600 bytes docs/user/exams/instructors_guide.rst | 16 +++++++++++++++- 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 docs/user/exams/instructor/buttons/exam_timeline.png create mode 100644 docs/user/exams/instructor/exam_timeline_example.png diff --git a/docs/user/exams/instructor/buttons/exam_timeline.png b/docs/user/exams/instructor/buttons/exam_timeline.png new file mode 100644 index 0000000000000000000000000000000000000000..9d1e127b3ce91ca57464163234a6b13c8442b933 GIT binary patch literal 1121 zcmeAS@N?(olHy`uVBq!ia0vp^)j({{!3HFCVofuE6k~CayA#8@b22Z19F}xPUq=Rp zjs4tz5?O(K&H|6fVg?4j!ywFfJby(B0|WCnPZ!6KinzCP_IpbwN*v$6HexDwW@pPX zO*Tyy>l9DdmZs(%D`e-CyjnE5J9^na#<_XLg_n+cF6VOKg ztZK%6_eac9#p^jv=!S^R7SJ(Mi~vilh<)=;Z;$8Kgrhdgb{VYlzs^5*hVZttyswsB z=TYnV{jG9s^rK&^uQ%*nSihSw=*_>hH8(SF>{_k&<9eCr#>eS1X5Krt=-sdQ6Ai0b zmKQqxkh;0DQ;z2$1M}wi(CU&ml4{AzBZ88ZH!QcBkQ2C^#r3ZK#7u)0`Ik(OIu)1} zsn5T8^n}yu7yQc0j?H*{dP3l|jLW=^ezywNT+6!Iq5N9x3WCFUUqX$?#(M_r5_ciY)|yKmQ}k)vU^2bb(wf{ zSj6>pemvY$w}owWxcmL$zWu9Zat??+_Pcdq`EyGXMM?H&Pk&xjR=B3PPWHg@RgZb6 zUC#20vuu%gFLY*sYKPq$P3bfvhfVP}TJ?y^M-G4ms#;=91l2a5{|K9BJRz$G7{NOweDjRjnnpg-zp=(Wp!>=*EY%TY7urv#B{#IS%$DWhx>0VTJ}BCHAUdahKZk+T&VpS&%|@@`_VD6qf#y&EMB^g`-M7PV{DJgU{*LXoxAJ>u>~gm9R{gX7rqA62@a%z{ zvLX5Akm|NS=WCa3-09^!{l+IzUY&}^K~-nnOn;P4Yk4rsmjhD=G^c?jBpdASEjlWi U{Qk!cV6nj9>FVdQ&MBb@0GI9+IsgCw literal 0 HcmV?d00001 diff --git a/docs/user/exams/instructor/exam_timeline_example.png b/docs/user/exams/instructor/exam_timeline_example.png new file mode 100644 index 0000000000000000000000000000000000000000..c356e7c6a1cad245d4432b7eff208f6bd7c945cb GIT binary patch literal 56600 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 literal 0 HcmV?d00001 diff --git a/docs/user/exams/instructors_guide.rst b/docs/user/exams/instructors_guide.rst index 49666841cee5..f82856586a94 100644 --- a/docs/user/exams/instructors_guide.rst +++ b/docs/user/exams/instructors_guide.rst @@ -451,10 +451,23 @@ If you want you can also enable the :ref:`second correction Date: Fri, 22 Sep 2023 16:06:45 +0200 Subject: [PATCH 14/17] Development: Ignore test courses for active user metrics (#7229) --- .../in/www1/artemis/config/MetricsBean.java | 34 +++++++------- .../artemis/repository/CourseRepository.java | 12 ++++- .../artemis/repository/ExamRepository.java | 7 +++ .../www1/artemis/config/MetricsBeanTest.java | 44 ++++++++++++++++--- 4 files changed, 72 insertions(+), 25 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/config/MetricsBean.java b/src/main/java/de/tum/in/www1/artemis/config/MetricsBean.java index 878d043d2d93..47e02b7a6233 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/MetricsBean.java +++ b/src/main/java/de/tum/in/www1/artemis/config/MetricsBean.java @@ -437,36 +437,32 @@ public void updatePublicArtemisMetrics() { if (!scheduledMetricsEnabled) { return; } - var startDate = System.currentTimeMillis(); + + final long startDate = System.currentTimeMillis(); // The authorization object has to be set because this method is not called by a user but by the scheduler SecurityUtils.setAuthorizationObject(); - ZonedDateTime now = ZonedDateTime.now(); + final ZonedDateTime now = ZonedDateTime.now(); - var courses = courseRepository.findAll(); + final List courses = courseRepository.findAllActiveWithoutTestCourses(now); // We set the number of students once to prevent multiple queries for the same date courses.forEach(course -> course.setNumberOfStudents(userRepository.countByGroupsIsContaining(course.getStudentGroupName()))); - ensureCourseInformationIsSet(courses); - var activeCourses = courses.stream() - .filter(course -> (course.getStartDate() == null || course.getStartDate().isBefore(now)) && (course.getEndDate() == null || course.getEndDate().isAfter(now))) - .toList(); - - List examsInActiveCourses = new ArrayList<>(); - activeCourses.forEach(course -> examsInActiveCourses.addAll(examRepository.findByCourseId(course.getId()))); + final List courseIds = courses.stream().mapToLong(Course::getId).boxed().toList(); + final List examsInActiveCourses = examRepository.findExamsInCourses(courseIds); // Update multi gauges - updateStudentsCourseMultiGauge(activeCourses); + updateStudentsCourseMultiGauge(courses); updateStudentsExamMultiGauge(examsInActiveCourses, courses); updateActiveUserMultiGauge(now); updateActiveExerciseMultiGauge(); updateExerciseMultiGauge(); // Update normal Gauges - activeCoursesGauge.set(activeCourses.size()); - coursesGauge.set(courses.size()); + activeCoursesGauge.set(courses.size()); + coursesGauge.set((int) courseRepository.count()); activeExamsGauge.set(examRepository.countAllActiveExams(now)); examsGauge.set((int) examRepository.count()); @@ -491,14 +487,18 @@ private void updateStudentsCourseMultiGauge(List activeCourses) { } private void updateStudentsExamMultiGauge(List examsInActiveCourses, List courses) { - studentsExamGauge.register(examsInActiveCourses.stream().map(exam -> MultiGauge.Row.of(Tags.of("examName", exam.getTitle(), - // The course semester.getCourse() is not populated (the semester property is not set) -> Use course from the courses list, which contains the semester - "semester", courses.stream().filter(course -> Objects.equals(course.getId(), exam.getCourse().getId())).findAny().map(Course::getSemester).orElse("No semester")), - studentExamRepository.findByExamId(exam.getId()).size())) + studentsExamGauge.register(examsInActiveCourses.stream() + .map(exam -> MultiGauge.Row.of(Tags.of("examName", exam.getTitle(), "semester", getExamSemester(courses, exam)), + studentExamRepository.findByExamId(exam.getId()).size())) // A mutable list is required here because otherwise the values can not be updated correctly .collect(Collectors.toCollection(ArrayList::new)), true); } + private String getExamSemester(final List courses, final Exam exam) { + // The exam.getCourse() is not populated (the semester property is not set) -> Use course from the courses list, which contains the semester + return courses.stream().filter(course -> Objects.equals(course.getId(), exam.getCourse().getId())).findAny().map(Course::getSemester).orElse("No semester"); + } + private void updateActiveExerciseMultiGauge() { var results = new ArrayList>(); var result = exerciseRepository.countActiveExercisesGroupByExerciseType(ZonedDateTime.now()); diff --git a/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java index 301f88ef300d..c7b97619da85 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/CourseRepository.java @@ -81,12 +81,22 @@ SELECT CASE WHEN (count(c) > 0) THEN true ELSE false END boolean informationSharingConfigurationIsOneOf(@Param("courseId") long courseId, @Param("values") Set values); @Query(""" - SELECT DISTINCT c FROM Course c + SELECT DISTINCT c + FROM Course c WHERE (c.startDate <= :now OR c.startDate IS NULL) AND (c.endDate >= :now OR c.endDate IS NULL) """) List findAllActive(@Param("now") ZonedDateTime now); + @Query(""" + SELECT DISTINCT c + FROM Course c + WHERE (c.startDate <= :now OR c.startDate IS NULL) + AND (c.endDate >= :now OR c.endDate IS NULL) + AND c.testCourse = false + """) + List findAllActiveWithoutTestCourses(@Param("now") ZonedDateTime now); + /** * Note: you should not add exercises or exercises+categories here, because this would make the query too complex and would take significantly longer * diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java index 5d6ff8d80255..bb02b590b690 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ExamRepository.java @@ -31,6 +31,13 @@ public interface ExamRepository extends JpaRepository { List findByCourseId(long courseId); + @Query(""" + SELECT DISTINCT exam + FROM Exam exam + WHERE exam.course.id IN :courses + """) + List findExamsInCourses(@Param("courses") Iterable courseId); + @Query(""" SELECT DISTINCT ex FROM Exam ex diff --git a/src/test/java/de/tum/in/www1/artemis/config/MetricsBeanTest.java b/src/test/java/de/tum/in/www1/artemis/config/MetricsBeanTest.java index 9f3332a8d751..452203bea36c 100644 --- a/src/test/java/de/tum/in/www1/artemis/config/MetricsBeanTest.java +++ b/src/test/java/de/tum/in/www1/artemis/config/MetricsBeanTest.java @@ -3,9 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.time.ZonedDateTime; -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; +import java.util.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -13,6 +11,7 @@ import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; import de.tum.in.www1.artemis.course.CourseUtilService; +import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.domain.TextExercise; import de.tum.in.www1.artemis.domain.enumeration.ExerciseType; import de.tum.in.www1.artemis.domain.enumeration.QuizMode; @@ -162,8 +161,7 @@ void testPublicMetricsActiveUsers() { @Test void testPublicMetricsCourses() { var activeCourse = courseUtilService.createCourse(); - activeCourse.setStartDate(ZonedDateTime.now().minusDays(1)); - activeCourse.setEndDate(ZonedDateTime.now().plusDays(1)); + activateCourse(activeCourse); courseRepository.save(activeCourse); var inactiveCourse = courseUtilService.createCourse(); @@ -173,8 +171,8 @@ void testPublicMetricsCourses() { metricsBean.updatePublicArtemisMetrics(); - var totalNumberOfCourses = courseRepository.count(); - var numberOfActiveCourses = courseRepository.findAllActive(ZonedDateTime.now()).size(); + long totalNumberOfCourses = courseRepository.count(); + long numberOfActiveCourses = countActiveCourses(); // Assert that there is at least one non-active course in the database so that the values returned from the metrics are different assertThat(numberOfActiveCourses).isNotEqualTo(totalNumberOfCourses); @@ -183,6 +181,33 @@ void testPublicMetricsCourses() { assertMetricEquals(numberOfActiveCourses, "artemis.statistics.public.active_courses"); } + @Test + void testPublicMetricsFilterTestCourses() { + var activeCourse = courseUtilService.createCourse(); + activateCourse(activeCourse); + courseRepository.save(activeCourse); + + var testCourse = courseUtilService.createCourse(); + activateCourse(testCourse); + testCourse.setTestCourse(true); + courseRepository.save(testCourse); + + metricsBean.updatePublicArtemisMetrics(); + + long totalNumberOfCourses = courseRepository.count(); + long numberOfActiveCourses = countActiveCourses(); + + assertMetricEquals(totalNumberOfCourses, "artemis.statistics.public.courses"); + assertMetricEquals(numberOfActiveCourses, "artemis.statistics.public.active_courses"); + } + + private long countActiveCourses() { + final List activeCourses = courseRepository.findAllActive(ZonedDateTime.now()); + // the test courses are only filtered for the metrics since for instructors/tutors/editors using Artemis + // test courses count as active, but they never contain active students/exams relevant for the metrics + return activeCourses.stream().filter(course -> !course.isTestCourse()).count(); + } + @Test void testPublicMetricsExams() { var users = userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0); @@ -455,4 +480,9 @@ private void assertMetricEquals(double expectedValue, String metricName, String. var gauge = meterRegistry.get(metricName).tags(tags).gauge(); assertThat(gauge.value()).isEqualTo(expectedValue); } + + private void activateCourse(final Course course) { + course.setStartDate(ZonedDateTime.now().minusDays(1)); + course.setEndDate(ZonedDateTime.now().plusDays(1)); + } } From 19ab08fad5cec815a9575d768162fbf45b0d134d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Sep 2023 16:07:55 +0200 Subject: [PATCH 15/17] Development: Bump docker/build-push-action from 4 to 5 (#7221) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8d07af38982d..cd8bbc709f78 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -127,7 +127,7 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and Push to GitHub Container Registry - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 if: ${{ steps.compute-tag.outputs.result != 'FALSE' }} with: # beware that the linux/arm64 build from the registry is using an amd64 compiled .war file as From 9405105868a50de123d443edc55f4a4acb8d24f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Sep 2023 16:08:08 +0200 Subject: [PATCH 16/17] Development: Bump docker/login-action from 2 to 3 (#7220) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cd8bbc709f78..5e0eb0a988fd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -120,7 +120,7 @@ jobs: uses: docker/setup-buildx-action@v3 # Build and Push to GitHub Container Registry - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 if: ${{ steps.compute-tag.outputs.result != 'FALSE' }} with: registry: ghcr.io From 6543ec619d3324e2f67951477bb9f9849ea5b518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20S=C3=B6lch?= Date: Fri, 22 Sep 2023 16:09:13 +0200 Subject: [PATCH 17/17] Development: Remove creation of successful file for E2E tests (#7219) --- .ci/E2E-tests/execute.sh | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.ci/E2E-tests/execute.sh b/.ci/E2E-tests/execute.sh index 971d05dea8a6..a75ebfb382e2 100755 --- a/.ci/E2E-tests/execute.sh +++ b/.ci/E2E-tests/execute.sh @@ -23,12 +23,3 @@ cd docker docker compose -f $COMPOSE_FILE pull artemis-cypress $DB nginx docker compose -f $COMPOSE_FILE build --build-arg WAR_FILE_STAGE=external_builder --no-cache --pull artemis-app docker compose -f $COMPOSE_FILE up --exit-code-from artemis-cypress -exitCode=$? -cd .. -echo "Cypress container exit code: $exitCode" -if [ $exitCode -eq 0 ] -then - touch .successful -else - echo "Not creating success file because the tests failed" -fi