diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/UpdateCourseCompetencyRelationDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/UpdateCourseCompetencyRelationDTO.java new file mode 100644 index 000000000000..d1bac2f5b3ca --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/UpdateCourseCompetencyRelationDTO.java @@ -0,0 +1,9 @@ +package de.tum.cit.aet.artemis.atlas.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.atlas.domain.competency.RelationType; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record UpdateCourseCompetencyRelationDTO(RelationType newRelationType) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java index 9ec942846bf8..fbe46aa979b0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java @@ -23,6 +23,7 @@ import de.tum.cit.aet.artemis.atlas.service.LearningObjectImportService; import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.exercise.service.ExerciseService; import de.tum.cit.aet.artemis.lecture.repository.LectureUnitCompletionRepository; @@ -41,9 +42,9 @@ public CompetencyService(CompetencyRepository competencyRepository, Authorizatio LearningPathService learningPathService, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService, CompetencyProgressRepository competencyProgressRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService, - LearningObjectImportService learningObjectImportService) { + LearningObjectImportService learningObjectImportService, CourseRepository courseRepository) { super(competencyProgressRepository, courseCompetencyRepository, competencyRelationRepository, competencyProgressService, exerciseService, lectureUnitService, - learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService); + learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, courseRepository); this.competencyRepository = competencyRepository; } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java index 01eb37cf8271..ea31bff10fad 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java @@ -27,6 +27,7 @@ import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyRelationDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; +import de.tum.cit.aet.artemis.atlas.dto.UpdateCourseCompetencyRelationDTO; import de.tum.cit.aet.artemis.atlas.repository.CompetencyProgressRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; @@ -39,6 +40,7 @@ import de.tum.cit.aet.artemis.core.dto.pageablesearch.CompetencyPageableSearchDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; +import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.core.util.PageUtil; import de.tum.cit.aet.artemis.exercise.domain.Exercise; @@ -77,11 +79,13 @@ public class CourseCompetencyService { private final LearningObjectImportService learningObjectImportService; + private final CourseRepository courseRepository; + public CourseCompetencyService(CompetencyProgressRepository competencyProgressRepository, CourseCompetencyRepository courseCompetencyRepository, CompetencyRelationRepository competencyRelationRepository, CompetencyProgressService competencyProgressService, ExerciseService exerciseService, LectureUnitService lectureUnitService, LearningPathService learningPathService, AuthorizationCheckService authCheckService, StandardizedCompetencyRepository standardizedCompetencyRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, - LearningObjectImportService learningObjectImportService) { + LearningObjectImportService learningObjectImportService, CourseRepository courseRepository) { this.competencyProgressRepository = competencyProgressRepository; this.courseCompetencyRepository = courseCompetencyRepository; this.competencyRelationRepository = competencyRelationRepository; @@ -93,6 +97,7 @@ public CourseCompetencyService(CompetencyProgressRepository competencyProgressRe this.standardizedCompetencyRepository = standardizedCompetencyRepository; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; this.learningObjectImportService = learningObjectImportService; + this.courseRepository = courseRepository; } /** @@ -123,6 +128,28 @@ public List findCourseCompetenciesWithProgressForUserByCourseI return findProgressForCompetenciesAndUser(competencies, userId); } + /** + * Updates the type of a course competency relation. + * + * @param courseId The id of the course for which to fetch the competencies + * @param courseCompetencyRelationId The id of the course competency relation to update + * @param updateCourseCompetencyRelationDTO The DTO containing the new relation type + * + */ + public void updateCourseCompetencyRelation(long courseId, long courseCompetencyRelationId, UpdateCourseCompetencyRelationDTO updateCourseCompetencyRelationDTO) { + var relation = competencyRelationRepository.findByIdElseThrow(courseCompetencyRelationId); + var course = courseRepository.findByIdElseThrow(courseId); + var headCompetency = relation.getHeadCompetency(); + var tailCompetency = relation.getTailCompetency(); + + if (!course.getId().equals(headCompetency.getCourse().getId()) || !course.getId().equals(tailCompetency.getCourse().getId())) { + throw new BadRequestAlertException("The relation does not belong to the course", ENTITY_NAME, "relationWrongCourse"); + } + + relation.setType(updateCourseCompetencyRelationDTO.newRelationType()); + competencyRelationRepository.save(relation); + } + /** * Search for all course competencies fitting a {@link CompetencyPageableSearchDTO search query}. The result is paged. * diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java index 3fc520a21378..4bf07e6e42ca 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java @@ -23,6 +23,7 @@ import de.tum.cit.aet.artemis.atlas.service.LearningObjectImportService; import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.exercise.service.ExerciseService; import de.tum.cit.aet.artemis.lecture.repository.LectureUnitCompletionRepository; @@ -41,9 +42,9 @@ public PrerequisiteService(PrerequisiteRepository prerequisiteRepository, Author LearningPathService learningPathService, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService, CompetencyProgressRepository competencyProgressRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService, - LearningObjectImportService learningObjectImportService) { + LearningObjectImportService learningObjectImportService, CourseRepository courseRepository) { super(competencyProgressRepository, courseCompetencyRepository, competencyRelationRepository, competencyProgressService, exerciseService, lectureUnitService, - learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService); + learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService, courseRepository); this.prerequisiteRepository = prerequisiteRepository; } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java index 449a92a1d171..8e93c6c73090 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java @@ -10,6 +10,7 @@ import java.util.Set; import java.util.stream.Collectors; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import org.slf4j.Logger; @@ -18,6 +19,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -32,6 +34,7 @@ import de.tum.cit.aet.artemis.atlas.dto.CompetencyJolPairDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyRelationDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; +import de.tum.cit.aet.artemis.atlas.dto.UpdateCourseCompetencyRelationDTO; import de.tum.cit.aet.artemis.atlas.repository.CompetencyProgressRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; @@ -350,6 +353,23 @@ public ResponseEntity generateCompetenciesFromCourseDescription(@PathVaria return ResponseEntity.accepted().build(); } + /** + * PATCH courses/:courseId/course-competencies/relations/:competencyRelationId update a relation type of an existing relation + * + * @param courseId the id of the course to which the competencies belong + * @param competencyRelationId the id of the competency relation to update + * @param updateCourseCompetencyRelationDTO the new relation type + * @return the ResponseEntity with status 200 (OK) + */ + @PatchMapping("courses/{courseId}/course-competencies/relations/{competencyRelationId}") + @EnforceAtLeastInstructorInCourse + public ResponseEntity updateCompetencyRelation(@PathVariable long courseId, @PathVariable long competencyRelationId, + @RequestBody @Valid UpdateCourseCompetencyRelationDTO updateCourseCompetencyRelationDTO) { + log.info("REST request to update a competency relation: {}", competencyRelationId); + courseCompetencyService.updateCourseCompetencyRelation(courseId, competencyRelationId, updateCourseCompetencyRelationDTO); + return ResponseEntity.noContent().build(); + } + /** * PUT courses/:courseId/course-competencies/:competencyId/jol/:jolValue : Sets the judgement of learning for a competency * diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-management-table.component.ts b/src/main/webapp/app/course/competencies/competency-management/competency-management-table.component.ts index e60ec966042a..0ee37dd08169 100644 --- a/src/main/webapp/app/course/competencies/competency-management/competency-management-table.component.ts +++ b/src/main/webapp/app/course/competencies/competency-management/competency-management-table.component.ts @@ -1,15 +1,7 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, inject } from '@angular/core'; import { CompetencyService } from 'app/course/competencies/competency.service'; import { AlertService } from 'app/core/util/alert.service'; -import { - CompetencyRelation, - CompetencyRelationDTO, - CompetencyWithTailRelationDTO, - CourseCompetency, - CourseCompetencyType, - dtoToCompetencyRelation, - getIcon, -} from 'app/entities/competency.model'; +import { CompetencyWithTailRelationDTO, CourseCompetency, CourseCompetencyType, getIcon } from 'app/entities/competency.model'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { filter, map } from 'rxjs/operators'; import { onError } from 'app/shared/util/global.utils'; @@ -30,7 +22,6 @@ import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; export class CompetencyManagementTableComponent implements OnInit, OnDestroy { @Input() courseId: number; @Input() courseCompetencies: CourseCompetency[]; - @Input() relations: CompetencyRelation[]; @Input() competencyType: CourseCompetencyType; @Input() standardizedCompetenciesEnabled: boolean; @@ -103,14 +94,7 @@ export class CompetencyManagementTableComponent implements OnInit, OnDestroy { */ updateDataAfterImportAll(res: Array) { const importedCompetencies = res.map((dto) => dto.competency).filter((element): element is CourseCompetency => !!element); - - const importedRelations = res - .map((dto) => dto.tailRelations) - .flat() - .filter((element): element is CompetencyRelationDTO => !!element) - .map((dto) => dtoToCompetencyRelation(dto)); this.courseCompetencies.push(...importedCompetencies); - this.relations.push(...importedRelations); } /** diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html index e541845c4ede..a70934974f15 100644 --- a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html +++ b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.html @@ -14,6 +14,10 @@

} + - -
-
- -
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- -
-
-
- @if (relationError) { - - } - -
-
- - - - - - - - - - - {{ node.label }} - - - - - - - - - {{ ('artemisApp.competency.relation.type.' + link.label | artemisTranslate).toUpperCase() }} - - - - - -
-
-
- - - diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.scss b/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.scss deleted file mode 100644 index e639f3e1bbfe..000000000000 --- a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.scss +++ /dev/null @@ -1,28 +0,0 @@ -.accordion-body { - overflow: hidden; - max-height: 60vh; -} - -.node { - text { - fill: var(--body-color); - } - - rect { - fill: var(--primary); - } -} - -.edge { - stroke: var(--body-color) !important; - marker-end: url(#arrow); -} - -#arrow { - stroke: var(--body-color); - fill: var(--body-color); -} - -.text-path { - fill: var(--body-color); -} diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.ts b/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.ts deleted file mode 100644 index 2fc93e4a8e63..000000000000 --- a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { Component, EventEmitter, Output, computed, input } from '@angular/core'; -import { faArrowsToEye } from '@fortawesome/free-solid-svg-icons'; -import { Edge, NgxGraphZoomOptions, Node } from '@swimlane/ngx-graph'; -import { CompetencyRelation, CompetencyRelationError, CompetencyRelationType, CourseCompetency } from 'app/entities/competency.model'; -import { Subject } from 'rxjs'; - -@Component({ - selector: 'jhi-competency-relation-graph', - templateUrl: './competency-relation-graph.component.html', - styleUrls: ['./competency-relation-graph.component.scss'], -}) -export class CompetencyRelationGraphComponent { - competencies = input([]); - relations = input([]); - - @Output() onRemoveRelation = new EventEmitter(); - @Output() onCreateRelation = new EventEmitter(); - - nodes = computed(() => { - this.update$.next(true); - return this.competencies().map((competency): Node => { - return { - id: `${competency.id}`, - label: competency.title, - }; - }); - }); - - edges = computed(() => { - this.update$.next(true); - return this.relations().map( - (relation): Edge => ({ - id: `edge${relation.id}`, - source: `${relation.tailCompetency?.id}`, - target: `${relation.headCompetency?.id}`, - label: relation.type, - data: { - id: relation.id, - }, - }), - ); - }); - - tailCompetencyId?: number; - headCompetencyId?: number; - relationType?: CompetencyRelationType; - relationError?: CompetencyRelationError = undefined; - update$: Subject = new Subject(); - center$: Subject = new Subject(); - zoomToFit$: Subject = new Subject(); - - // icons - protected readonly faArrowsToEye = faArrowsToEye; - - // constants - protected readonly competencyRelationType = CompetencyRelationType; - protected readonly errorMessage: Record = { - CIRCULAR: 'artemisApp.competency.relation.createsCircularRelation', - EXISTING: 'artemisApp.competency.relation.relationAlreadyExists', - SELF: 'artemisApp.competency.relation.selfRelation', - }; - - /** - * creates a relation with the currently entered data if it would not cause an error - */ - createRelation() { - this.validate(); - if (this.relationError) { - return; - } - const relation: CompetencyRelation = { - tailCompetency: { id: this.tailCompetencyId }, - headCompetency: { id: this.headCompetencyId }, - type: this.relationType, - }; - this.onCreateRelation.emit(relation); - } - - /** - * removes the relation - * @param edge the edge symbolizing the relation - */ - removeRelation(edge: Edge) { - this.onRemoveRelation.emit(edge.data.id); - } - - centerView() { - this.zoomToFit$.next({ autoCenter: true }); - this.center$.next(true); - } - - /** - * Validates if the currently entered data would cause an error and sets relationError accordingly - */ - validate(): void { - if (!this.tailCompetencyId || !this.headCompetencyId || !this.relationType) { - this.relationError = undefined; - return; - } - if (this.headCompetencyId === this.tailCompetencyId) { - this.relationError = CompetencyRelationError.SELF; - return; - } - if (this.doesRelationAlreadyExist()) { - this.relationError = CompetencyRelationError.EXISTING; - return; - } - if (this.containsCircularRelation()) { - this.relationError = CompetencyRelationError.CIRCULAR; - return; - } - this.relationError = undefined; - } - - /** - * checks if the currently entered data is equal to an existing relation - * @private - */ - private doesRelationAlreadyExist(): boolean { - return !!this.edges().find((edge) => edge.source === this.tailCompetencyId?.toString() && edge.target === this.headCompetencyId?.toString()); - } - - /** - * Checks if the currently entered data would create a circular relation - * - * @private - */ - private containsCircularRelation(): boolean { - if (!this.tailCompetencyId || !this.headCompetencyId || !this.relationType) { - return false; - } - return this.doesCreateCircularRelation(this.nodes(), this.edges(), { - source: this.tailCompetencyId! + '', - target: this.headCompetencyId! + '', - label: this.relationType!, - } as Edge); - } - - /** - * Checks if adding an edge would create a circular relation - * @param {Node[]} nodes an array of all existing nodes of a graph - * @param {Edge[]} edges an array of all existing edges of a graph - * @param {Edge} edgeToAdd the edge that you try to add to the graph - * - * @returns {boolean} whether or not adding the provided edge would result in a circle in the graph - */ - private doesCreateCircularRelation(nodes: Node[], edges: Edge[], edgeToAdd: Edge): boolean { - const edgesWithNewEdge = JSON.parse(JSON.stringify(edges)); - edgesWithNewEdge.push(edgeToAdd); - const graph = new Graph(); - for (const node of nodes) { - graph.addVertex(new Vertex(node.id)); - } - for (const edge of edgesWithNewEdge) { - const headVertex = graph.vertices.find((vertex: Vertex) => vertex.getLabel() === edge.target); - const tailVertex = graph.vertices.find((vertex: Vertex) => vertex.getLabel() === edge.source); - if (headVertex === undefined || tailVertex === undefined) { - throw new TypeError('Every edge needs a source or a target.'); - } - // only extends and assumes relations are considered when checking for circles because only they don't make sense - // MATCHES relations are considered in the next step by merging the edges and combining the adjacencyLists - switch (edge.label) { - case 'EXTENDS': - case 'ASSUMES': { - graph.addEdge(tailVertex, headVertex); - break; - } - } - } - // combine vertices that are connected through MATCHES - for (const edge of edgesWithNewEdge) { - if (edge.label === 'MATCHES') { - const headVertex = graph.vertices.find((vertex: Vertex) => vertex.getLabel() === edge.target); - const tailVertex = graph.vertices.find((vertex: Vertex) => vertex.getLabel() === edge.source); - if (headVertex === undefined || tailVertex === undefined) { - throw new TypeError('Every edge needs a source or a target.'); - } - if (headVertex.getAdjacencyList().includes(tailVertex) || tailVertex.getAdjacencyList().includes(headVertex)) { - return true; - } - // create a merged vertex - const mergedVertex = new Vertex(tailVertex.getLabel() + ', ' + headVertex.getLabel()); - // add all neighbours to merged vertex - mergedVertex.getAdjacencyList().push(...headVertex.getAdjacencyList()); - mergedVertex.getAdjacencyList().push(...tailVertex.getAdjacencyList()); - // update every vertex that initially had one of the two merged vertices as neighbours to now reference the merged vertex - for (const vertex of graph.vertices) { - for (const adjacentVertex of vertex.getAdjacencyList()) { - if (adjacentVertex.getLabel() === headVertex.getLabel() || adjacentVertex.getLabel() === tailVertex.getLabel()) { - const index = vertex.getAdjacencyList().indexOf(adjacentVertex, 0); - if (index > -1) { - vertex.getAdjacencyList().splice(index, 1); - } - vertex.getAdjacencyList().push(mergedVertex); - } - } - } - } - } - return graph.hasCycle(); - } - - /** - * Keeps order of elements as-is in the keyvalue pipe - */ - keepOrder = () => { - return 0; - }; -} - -/** - * A class that represents a vertex in a graph - * @class - * - * @constructor - * - * @property label a label to identify the vertex (we use the node id) - * @property beingVisited is the vertex the one that is currently being visited during the graph traversal - * @property visited has this vertex been visited before - * @property adjacencyList an array that contains all adjacent vertices - */ -class Vertex { - private readonly label: string; - private beingVisited: boolean; - private visited: boolean; - private readonly adjacencyList: Vertex[]; - - constructor(label: string) { - this.label = label; - this.adjacencyList = []; - } - - getLabel(): string { - return this.label; - } - - addNeighbor(adjacent: Vertex): void { - this.adjacencyList.push(adjacent); - } - - getAdjacencyList(): Vertex[] { - return this.adjacencyList; - } - - isBeingVisited(): boolean { - return this.beingVisited; - } - - setBeingVisited(beingVisited: boolean): void { - this.beingVisited = beingVisited; - } - - isVisited(): boolean { - return this.visited; - } - - setVisited(visited: boolean) { - this.visited = visited; - } -} - -/** - * A class that represents a graph - * @class - * - * @constructor - * - * @property vertices an array of all vertices in the graph (edges are represented by the adjacent vertices property of each vertex) - */ -class Graph { - vertices: Vertex[]; - - constructor() { - this.vertices = []; - } - - public addVertex(vertex: Vertex): void { - this.vertices.push(vertex); - } - - public addEdge(from: Vertex, to: Vertex): void { - from.addNeighbor(to); - } - - /** - * Checks if the graph contains a circle - * - * @returns {boolean} whether or not the graph contains a circle - */ - public hasCycle(): boolean { - // we have to check for every vertex if it is part of a cycle in case the graph is not connected - for (const vertex of this.vertices) { - if (!vertex.isVisited() && this.vertexHasCycle(vertex)) { - return true; - } - } - return false; - } - - /** - * Checks if a vertex is part of a circle - * - * @returns {boolean} whether or not the vertex is part of a circle - */ - private vertexHasCycle(sourceVertex: Vertex): boolean { - sourceVertex.setBeingVisited(true); - - for (const neighbor of sourceVertex.getAdjacencyList()) { - if (neighbor.isBeingVisited() || (!neighbor.isVisited() && this.vertexHasCycle(neighbor))) { - // backward edge exists - return true; - } - } - - sourceVertex.setBeingVisited(false); - sourceVertex.setVisited(true); - return false; - } -} diff --git a/src/main/webapp/app/course/competencies/competency.module.ts b/src/main/webapp/app/course/competencies/competency.module.ts index d7b4c6da30b5..6065b7e90ccb 100644 --- a/src/main/webapp/app/course/competencies/competency.module.ts +++ b/src/main/webapp/app/course/competencies/competency.module.ts @@ -3,7 +3,6 @@ import { RouterModule } from '@angular/router'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { CompetencyManagementComponent } from './competency-management/competency-management.component'; import { CompetencyCardComponent } from 'app/course/competencies/competency-card/competency-card.component'; import { CompetenciesPopoverComponent } from './competencies-popover/competencies-popover.component'; import { NgxGraphModule } from '@swimlane/ngx-graph'; @@ -15,7 +14,6 @@ import { CourseDescriptionFormComponent } from 'app/course/competencies/generate import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; import { IrisModule } from 'app/iris/iris.module'; import { TaxonomySelectComponent } from 'app/course/competencies/taxonomy-select/taxonomy-select.component'; -import { CompetencyRelationGraphComponent } from 'app/course/competencies/competency-management/competency-relation-graph.component'; import { CompetencyAccordionComponent } from 'app/course/competencies/competency-accordion/competency-accordion.component'; import { ArtemisCourseExerciseRowModule } from 'app/overview/course-exercises/course-exercise-row.module'; import { RatingModule } from 'app/exercises/shared/rating/rating.module'; @@ -49,13 +47,11 @@ import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown CompetencySearchComponent, CompetencyRecommendationDetailComponent, CourseDescriptionFormComponent, - CompetencyManagementComponent, CompetencyCardComponent, CompetencyAccordionComponent, CompetenciesPopoverComponent, ImportCompetenciesTableComponent, TaxonomySelectComponent, - CompetencyRelationGraphComponent, ], exports: [ CompetencyCardComponent, diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.html b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.html new file mode 100644 index 000000000000..d90c81fd3714 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.html @@ -0,0 +1,36 @@ +
+ + + + + + + + + + + + + + + + + + + {{ ('artemisApp.courseCompetency.relations.relationTypes.' + link.label | artemisTranslate).toUpperCase() }} + + + + + +
diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.scss b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.scss new file mode 100644 index 000000000000..add2bfbd3928 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.scss @@ -0,0 +1,14 @@ +.course-competencies-graph-container { + #arrow { + stroke: var(--body-color); + fill: var(--body-color); + } + + .selected { + stroke: var(--bs-primary); + } + + .text-path { + fill: var(--body-color); + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.ts b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.ts new file mode 100644 index 000000000000..448d2e4b1d77 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component.ts @@ -0,0 +1,80 @@ +import { Component, computed, effect, input, model, output, signal } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faFileImport } from '@fortawesome/free-solid-svg-icons'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { CompetencyRelationDTO, CourseCompetency } from 'app/entities/competency.model'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { Edge, NgxGraphModule, Node } from '@swimlane/ngx-graph'; +import { Subject } from 'rxjs'; +import { SizeUpdate } from 'app/course/learning-paths/components/competency-node/competency-node.component'; +import { CourseCompetencyRelationNodeComponent } from 'app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component'; + +@Component({ + selector: 'jhi-course-competencies-relation-graph', + standalone: true, + imports: [FontAwesomeModule, NgbAccordionModule, NgxGraphModule, ArtemisSharedModule, CourseCompetencyRelationNodeComponent], + templateUrl: './course-competencies-relation-graph.component.html', + styleUrl: './course-competencies-relation-graph.component.scss', +}) +export class CourseCompetenciesRelationGraphComponent { + protected readonly faFileImport = faFileImport; + + readonly courseCompetencies = input.required(); + readonly relations = input.required(); + + readonly selectedRelationId = model.required(); + + readonly onCourseCompetencySelection = output(); + + readonly update$ = new Subject(); + readonly center$ = new Subject(); + + readonly nodes = signal([]); + + readonly edges = computed(() => { + return this.relations().map((relation) => ({ + id: `edge-${relation.id}`, + source: `${relation.headCompetencyId}`, + target: `${relation.tailCompetencyId}`, + label: relation.relationType, + data: { + id: relation.id, + }, + })); + }); + + constructor() { + effect( + () => { + return this.nodes.set( + this.courseCompetencies().map( + (courseCompetency): Node => ({ + id: courseCompetency.id!.toString(), + label: courseCompetency.title, + data: { + id: courseCompetency.id, + type: courseCompetency.type, + }, + }), + ), + ); + }, + { allowSignalWrites: true }, + ); + } + + protected selectRelation(relationId: number): void { + this.selectedRelationId.set(relationId); + } + + protected setNodeDimension(sizeUpdate: SizeUpdate): void { + this.nodes.update((nodes) => + nodes.map((node) => { + if (node.id === sizeUpdate.id) { + node.dimension = sizeUpdate.dimension; + } + return node; + }), + ); + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.html b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.html new file mode 100644 index 000000000000..5e14b7fb7680 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.html @@ -0,0 +1,33 @@ +
+
+
+ + +
+
+
+
+ @if (isLoading()) { +
+
+ +
+
+ } @else { +
+ +
+ + } +
+
diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.scss b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.scss new file mode 100644 index 000000000000..bc1c8d310a04 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.scss @@ -0,0 +1,5 @@ +.course-competencies-graph-modal { + height: 90vh; + max-height: 700px; + overflow: hidden; +} diff --git a/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.ts b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.ts new file mode 100644 index 000000000000..df0547c57a44 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component.ts @@ -0,0 +1,57 @@ +import { Component, effect, inject, input, signal, viewChild } from '@angular/core'; +import { CourseCompetencyApiService } from 'app/course/competencies/services/course-competency-api.service'; +import { CompetencyRelationDTO, CourseCompetency } from 'app/entities/competency.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { CompetencyGraphComponent } from 'app/course/learning-paths/components/competency-graph/competency-graph.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { CourseCompetencyRelationFormComponent } from 'app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component'; +import { CourseCompetenciesRelationGraphComponent } from '../course-competencies-relation-graph/course-competencies-relation-graph.component'; + +@Component({ + selector: 'jhi-course-competencies-relation-modal', + standalone: true, + imports: [ArtemisSharedCommonModule, CompetencyGraphComponent, CourseCompetenciesRelationGraphComponent, CourseCompetencyRelationFormComponent], + templateUrl: './course-competencies-relation-modal.component.html', + styleUrl: './course-competencies-relation-modal.component.scss', +}) +export class CourseCompetenciesRelationModalComponent { + private readonly courseCompetencyApiService = inject(CourseCompetencyApiService); + private readonly alertService = inject(AlertService); + private readonly activeModal = inject(NgbActiveModal); + + private readonly courseCompetencyRelationFormComponent = viewChild.required(CourseCompetencyRelationFormComponent); + + readonly courseId = input.required(); + readonly courseCompetencies = input.required(); + + readonly selectedRelationId = signal(undefined); + + readonly isLoading = signal(false); + readonly relations = signal([]); + + constructor() { + effect(() => this.loadRelations(this.courseId()), { allowSignalWrites: true }); + } + + private async loadRelations(courseId: number): Promise { + try { + this.isLoading.set(true); + const relations = await this.courseCompetencyApiService.getCourseCompetencyRelationsByCourseId(courseId); + this.relations.set(relations); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } + + protected selectCourseCompetency(courseCompetencyId: number) { + this.courseCompetencyRelationFormComponent().selectCourseCompetency(courseCompetencyId); + } + + protected closeModal(): void { + this.activeModal.close(); + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.html b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.html new file mode 100644 index 000000000000..5d8be2495c7c --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.html @@ -0,0 +1,70 @@ +
+
+ + +
+
+ + +
+
+ + +
+
+ @if (exactRelationAlreadyExists()) { + + } @else if (relationAlreadyExists()) { + + } @else { + + } +
+ @if (showCircularDependencyError()) { + + } +
diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.scss b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.scss new file mode 100644 index 000000000000..84ed3ff63b6a --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.scss @@ -0,0 +1,4 @@ +.course-competency-relation-form-container { + background-color: var(--bs-body-bg); + border-radius: var(--bs-border-radius-lg); +} diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.ts b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.ts new file mode 100644 index 000000000000..7f173f4968a3 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component.ts @@ -0,0 +1,306 @@ +import { Component, computed, effect, inject, input, model, signal } from '@angular/core'; +import { CompetencyRelationDTO, CompetencyRelationType, CourseCompetency, UpdateCourseCompetencyRelationDTO } from 'app/entities/competency.model'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { CourseCompetencyApiService } from 'app/course/competencies/services/course-competency-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'jhi-course-competency-relation-form', + standalone: true, + imports: [ArtemisSharedCommonModule], + templateUrl: './course-competency-relation-form.component.html', + styleUrl: './course-competency-relation-form.component.scss', +}) +export class CourseCompetencyRelationFormComponent { + protected readonly faSpinner = faSpinner; + + protected readonly competencyRelationType = CompetencyRelationType; + + private readonly courseCompetencyApiService = inject(CourseCompetencyApiService); + private readonly alertService = inject(AlertService); + + readonly courseId = input.required(); + readonly courseCompetencies = input.required(); + readonly relations = model.required(); + readonly selectedRelationId = model.required(); + + readonly headCompetencyId = signal(undefined); + readonly tailCompetencyId = signal(undefined); + readonly relationType = model(undefined); + + readonly isLoading = signal(false); + + readonly relationAlreadyExists = computed(() => this.getRelation(this.headCompetencyId(), this.tailCompetencyId()) !== undefined); + readonly exactRelationAlreadyExists = computed(() => this.getExactRelation(this.headCompetencyId(), this.tailCompetencyId(), this.relationType()) !== undefined); + + private readonly selectableTailCourseCompetencyIds = computed(() => { + if (this.headCompetencyId() && this.relationType()) { + return this.getSelectableTailCompetencyIds(this.headCompetencyId()!, this.relationType()!); + } + return this.courseCompetencies().map(({ id }) => id!); + }); + + readonly showCircularDependencyError = computed(() => this.tailCompetencyId() && !this.selectableTailCourseCompetencyIds().includes(this.tailCompetencyId()!)); + + constructor() { + effect(() => this.selectRelation(this.selectedRelationId()), { allowSignalWrites: true }); + } + + protected isCourseCompetencySelectable(courseCompetencyId: number): boolean { + return this.selectableTailCourseCompetencyIds().includes(courseCompetencyId); + } + + private selectRelation(relationId?: number): void { + const relation = this.relations().find(({ id }) => id === relationId); + if (relation) { + this.headCompetencyId.set(relation?.headCompetencyId); + this.tailCompetencyId.set(relation?.tailCompetencyId); + this.relationType.set(relation?.relationType); + } + } + + public selectCourseCompetency(courseCompetencyId: number): void { + if (!this.headCompetencyId()) { + this.selectHeadCourseCompetency(courseCompetencyId); + } else if (!this.tailCompetencyId()) { + this.selectTailCourseCompetency(courseCompetencyId); + } else { + this.selectHeadCourseCompetency(courseCompetencyId); + } + } + + protected selectHeadCourseCompetency(headId: number) { + this.headCompetencyId.set(headId); + this.tailCompetencyId.set(undefined); + this.selectedRelationId.set(undefined); + } + + protected selectTailCourseCompetency(tailId: number) { + this.tailCompetencyId.set(tailId); + const existingRelation = this.getRelation(this.headCompetencyId(), this.tailCompetencyId()); + if (existingRelation) { + this.selectedRelationId.set(existingRelation.id); + } else { + this.selectedRelationId.set(undefined); + } + } + + protected async createRelation(): Promise { + try { + this.isLoading.set(true); + const courseCompetencyRelation = await this.courseCompetencyApiService.createCourseCompetencyRelation(this.courseId(), { + headCompetencyId: this.headCompetencyId()!, + tailCompetencyId: Number(this.tailCompetencyId()!), + relationType: this.relationType()!, + }); + this.relations.update((relations) => [...relations, courseCompetencyRelation]); + this.selectedRelationId.set(courseCompetencyRelation.id!); + } catch (error) { + this.alertService.error(error.message); + } finally { + this.isLoading.set(false); + } + } + + protected getExactRelation(headCompetencyId?: number, tailCompetencyId?: number, relationType?: CompetencyRelationType): CompetencyRelationDTO | undefined { + return this.relations().find( + (relation) => relation.headCompetencyId === headCompetencyId && relation.tailCompetencyId === tailCompetencyId && relation.relationType === relationType, + ); + } + + protected getRelation(headCompetencyId?: number, tailCompetencyId?: number): CompetencyRelationDTO | undefined { + return this.relations().find((relation) => relation.headCompetencyId === headCompetencyId && relation.tailCompetencyId === tailCompetencyId); + } + + protected async updateRelation(): Promise { + try { + this.isLoading.set(true); + const newRelationType = this.relationType()!; + await this.courseCompetencyApiService.updateCourseCompetencyRelation(this.courseId(), this.selectedRelationId()!, { + newRelationType: newRelationType, + }); + this.relations.update((relations) => + relations.map((relation) => { + if (relation.id === this.selectedRelationId()) { + return { ...relation, relationType: newRelationType }; + } + return relation; + }), + ); + } catch (error) { + this.alertService.error(error.message); + } finally { + this.isLoading.set(false); + } + } + + protected async deleteRelation(): Promise { + try { + this.isLoading.set(true); + const deletedRelation = this.relations().find( + ({ headCompetencyId, tailCompetencyId, relationType }) => + headCompetencyId == this.headCompetencyId() && tailCompetencyId == this.tailCompetencyId() && relationType === this.relationType(), + ); + await this.courseCompetencyApiService.deleteCourseCompetencyRelation(this.courseId(), deletedRelation!.id!); + this.relations.update((relations) => relations.filter(({ id }) => id !== deletedRelation!.id)); + this.selectedRelationId.set(undefined); + } catch (error) { + this.alertService.error(error.message); + } finally { + this.isLoading.set(false); + } + } + + /** + * Function to get the selectable tail competency ids for the given head + * competency and relation type without creating a cyclic dependency + * + * @param headCompetencyId The selected head competency id + * @param relationType The selected relation type + * @private + * + * @returns The selectable tail competency ids + */ + private getSelectableTailCompetencyIds(headCompetencyId: number, relationType: CompetencyRelationType): number[] { + return this.courseCompetencies() + .map(({ id }) => id!) + .filter((id) => id !== headCompetencyId) // Exclude the head itself + .filter((id) => { + let relations = this.relations(); + const existingRelation = this.getRelation(headCompetencyId, id); + if (existingRelation) { + relations = relations.filter((relation) => relation.id !== existingRelation.id); + } + const potentialRelation: CompetencyRelationDTO = { + headCompetencyId: headCompetencyId, + tailCompetencyId: id, + relationType: relationType, + }; + return !this.detectCycleInRelations(relations.concat(potentialRelation), this.courseCompetencies().length); + }); + } + + /** + * Function to detect cycles in the competency relations + * @param relations The list of competency relations + * @param numOfCompetencies The total number of competencies + * @private + * + * @returns True if a cycle is detected, false otherwise + */ + private detectCycleInRelations(relations: CompetencyRelationDTO[], numOfCompetencies: number): boolean { + // Create a map to store the competency IDs and map them to incremental indices + const idToIndexMap = new Map(); + let currentIndex = 0; + + // map the competency IDs to incremental indices + relations.forEach((relation) => { + const tail = relation.tailCompetencyId!; + const head = relation.headCompetencyId!; + + if (!idToIndexMap.has(tail)) { + idToIndexMap.set(tail, currentIndex++); + } + if (!idToIndexMap.has(head)) { + idToIndexMap.set(head, currentIndex++); + } + }); + + const unionFind = new UnionFind(numOfCompetencies); + + // Apply Union-Find based on the MATCHES relations + relations.forEach((relation) => { + if (relation.relationType === CompetencyRelationType.MATCHES) { + const tailIndex = idToIndexMap.get(relation.tailCompetencyId!); + const headIndex = idToIndexMap.get(relation.headCompetencyId!); + + if (tailIndex !== undefined && headIndex !== undefined) { + // Perform union operation to group matching course competencies into sets + unionFind.union(tailIndex, headIndex); + } + } + }); + + // Build the reduced graph for EXTENDS and ASSUMES relations + const reducedGraph: number[][] = Array.from({ length: numOfCompetencies }, () => []); + + relations.forEach((relation) => { + const tail = unionFind.find(idToIndexMap.get(relation.tailCompetencyId!)!); + const head = unionFind.find(idToIndexMap.get(relation.headCompetencyId!)!); + + if (relation.relationType === CompetencyRelationType.EXTENDS || relation.relationType === CompetencyRelationType.ASSUMES) { + reducedGraph[tail].push(head); + } + }); + + return this.hasCycle(reducedGraph, numOfCompetencies); + } + + private hasCycle(graph: number[][], noOfCourseCompetencies: number): boolean { + const visited: boolean[] = Array(noOfCourseCompetencies).fill(false); + const recursionStack: boolean[] = Array(noOfCourseCompetencies).fill(false); + + // Depth-first search to detect cycles + const depthFirstSearch = (v: number): boolean => { + visited[v] = true; + recursionStack[v] = true; + + for (const neighbor of graph[v] || []) { + if (!visited[neighbor]) { + if (depthFirstSearch(neighbor)) return true; + } else if (recursionStack[neighbor]) { + return true; + } + } + + recursionStack[v] = false; + return false; + }; + + for (let node = 0; node < noOfCourseCompetencies; node++) { + if (!visited[node]) { + if (depthFirstSearch(node)) { + return true; + } + } + } + return false; + } +} + +// Union-Find (Disjoint Set) class (https://en.wikipedia.org/wiki/Disjoint-set_data_structure -> union by rank) +export class UnionFind { + parent: number[]; + rank: number[]; + + constructor(size: number) { + this.parent = Array.from({ length: size }, (_, index) => index); + this.rank = Array(size).fill(1); + } + + // Find the representative of the set that contains the `competencyId` + public find(competencyId: number): number { + if (this.parent[competencyId] !== competencyId) { + this.parent[competencyId] = this.find(this.parent[competencyId]); // Path compression + } + return this.parent[competencyId]; + } + + // Union the sets containing `tailCompetencyId` and `headCompetencyId` + public union(tailCompetencyId: number, headCompetencyId: number) { + const rootU = this.find(tailCompetencyId); + const rootV = this.find(headCompetencyId); + if (rootU !== rootV) { + // Union by rank + if (this.rank[rootU] > this.rank[rootV]) { + this.parent[rootV] = rootU; + } else if (this.rank[rootU] < this.rank[rootV]) { + this.parent[rootU] = rootV; + } else { + this.parent[rootV] = rootU; + this.rank[rootU] += 1; + } + } + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.html b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.html new file mode 100644 index 000000000000..c7c8821f577f --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.html @@ -0,0 +1,17 @@ +
+
+ + + +
+ {{ courseCompetencyNode().label }} +
diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.scss b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.scss new file mode 100644 index 000000000000..baa3d06976c5 --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.scss @@ -0,0 +1,20 @@ +.competency-node { + white-space: nowrap; + background-color: var(--bs-body-bg); + border-radius: calc(var(--bs-border-radius-lg) + 6px); + padding: 10px 12px; + + .progress-container { + color: var(--bs-white); + padding: 2px 8px; + border-radius: var(--bs-border-radius-lg); + } + + .competency-container { + background-color: var(--bs-green); + } + + .prerequisite-container { + background-color: var(--bs-yellow); + } +} diff --git a/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.ts b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.ts new file mode 100644 index 000000000000..48708b6ed23c --- /dev/null +++ b/src/main/webapp/app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component.ts @@ -0,0 +1,38 @@ +import { AfterViewInit, Component, ElementRef, computed, inject, input, output } from '@angular/core'; +import { SizeUpdate } from 'app/course/learning-paths/components/competency-node/competency-node.component'; +import { Node } from '@swimlane/ngx-graph'; +import { CourseCompetencyType } from 'app/entities/competency.model'; +import { NgClass } from '@angular/common'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; + +@Component({ + selector: 'jhi-course-competency-relation-node', + standalone: true, + imports: [NgClass, TranslateDirective, NgbTooltipModule, ArtemisSharedModule], + templateUrl: './course-competency-relation-node.component.html', + styleUrl: './course-competency-relation-node.component.scss', +}) +export class CourseCompetencyRelationNodeComponent implements AfterViewInit { + protected readonly CourseCompetencyType = CourseCompetencyType; + // height of node element in pixels + private readonly nodeHeight = 45.59; + + private readonly element = inject(ElementRef); + + readonly courseCompetencyNode = input.required(); + readonly courseCompetencyType = computed(() => this.courseCompetencyNode().data.type!); + + readonly onSizeSet = output(); + + ngAfterViewInit(): void { + this.setDimensions(this.element); + } + + setDimensions(element: ElementRef): void { + const width: number = element.nativeElement.offsetWidth; + const height = this.nodeHeight; + this.onSizeSet.emit({ id: `${this.courseCompetencyNode().id}`, dimension: { height, width } }); + } +} diff --git a/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts b/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts index c7d69c3674c9..26dbb518ba68 100644 --- a/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts +++ b/src/main/webapp/app/course/competencies/services/course-competency-api.service.ts @@ -1,6 +1,12 @@ import { Injectable } from '@angular/core'; import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; -import { CompetencyRelationDTO, CompetencyWithTailRelationDTO, CourseCompetency, CourseCompetencyImportOptionsDTO } from 'app/entities/competency.model'; +import { + CompetencyRelationDTO, + CompetencyWithTailRelationDTO, + CourseCompetency, + CourseCompetencyImportOptionsDTO, + UpdateCourseCompetencyRelationDTO, +} from 'app/entities/competency.model'; @Injectable({ providedIn: 'root' }) export class CourseCompetencyApiService extends BaseApiHttpService { @@ -10,23 +16,31 @@ export class CourseCompetencyApiService extends BaseApiHttpService { return this.basePath.replace('$courseId', courseId.toString()); } - importAllByCourseId(courseId: number, courseCompetencyImportOptions: CourseCompetencyImportOptionsDTO): Promise { - return this.post(`${this.getPath(courseId)}/import-all`, courseCompetencyImportOptions); + async importAllByCourseId(courseId: number, courseCompetencyImportOptions: CourseCompetencyImportOptionsDTO): Promise { + return await this.post(`${this.getPath(courseId)}/import-all`, courseCompetencyImportOptions); } - createCourseCompetencyRelation(courseId: number, relation: CompetencyRelationDTO): Promise { - return this.post(`${this.getPath(courseId)}/relations`, relation); + async createCourseCompetencyRelation(courseId: number, relation: CompetencyRelationDTO): Promise { + return await this.post(`${this.getPath(courseId)}/relations`, relation); } - deleteCourseCompetencyRelation(courseId: number, relationId: number): Promise { - return this.delete(`${this.getPath(courseId)}/relations/${relationId}`); + async updateCourseCompetencyRelation(courseId: number, relationId: number, updateCourseCompetencyRelationDTO: UpdateCourseCompetencyRelationDTO): Promise { + return await this.patch(`${this.getPath(courseId)}/relations/${relationId}`, updateCourseCompetencyRelationDTO); } - getCourseCompetencyRelations(courseId: number): Promise { - return this.get(`${this.getPath(courseId)}/relations`); + async deleteCourseCompetencyRelation(courseId: number, relationId: number): Promise { + return await this.delete(`${this.getPath(courseId)}/relations/${relationId}`); } - getCourseCompetenciesByCourseId(courseId: number): Promise { - return this.get(`${this.getPath(courseId)}`); + async getCourseCompetencyRelationsByCourseId(courseId: number): Promise { + return await this.get(`${this.getPath(courseId)}/relations`); + } + + async getCourseCompetencyRelations(courseId: number): Promise { + return await this.get(`${this.getPath(courseId)}/relations`); + } + + async getCourseCompetenciesByCourseId(courseId: number): Promise { + return await this.get(`${this.getPath(courseId)}`); } } diff --git a/src/main/webapp/app/entities/competency.model.ts b/src/main/webapp/app/entities/competency.model.ts index 2a24f304e04c..b4bd6321b458 100644 --- a/src/main/webapp/app/entities/competency.model.ts +++ b/src/main/webapp/app/entities/competency.model.ts @@ -53,6 +53,10 @@ export abstract class BaseCompetency implements BaseEntity { taxonomy?: CompetencyTaxonomy; } +export interface UpdateCourseCompetencyRelationDTO { + newRelationType: CompetencyRelationType; +} + export abstract class CourseCompetency extends BaseCompetency { softDueDate?: dayjs.Dayjs; masteryThreshold?: number; diff --git a/src/main/webapp/i18n/de/competency.json b/src/main/webapp/i18n/de/competency.json index cb07c639df52..aaef36b9f34a 100644 --- a/src/main/webapp/i18n/de/competency.json +++ b/src/main/webapp/i18n/de/competency.json @@ -172,8 +172,9 @@ "softDueDate": "Empfohlen bis", "masteryThreshold": "Schwellwert zum Erreichen der Kompetenz", "manage": { - "helpButton": "Hilfe", - "importAllButton": "Alles eines Kurses importieren" + "importAllButton": "Alles eines Kurses importieren", + "editRelationsButton": "Beziehungen bearbeiten", + "helpButton": "Hilfe" }, "importSettings": { "importRelationsLabel": "Beziehungen importieren", @@ -242,6 +243,36 @@ "participationRate": "Teilnahmerate" } }, + "relations": { + "modalTitle": "Beziehungen zwischen Kurskompetenzen", + "relationTypes": { + "ASSUMES": "Setzt voraus", + "EXTENDS": "Erweitert", + "MATCHES": "Stimmt überein mit" + }, + "form": { + "headCourseCompetencyLabel": "Startkurskompetenz", + "headCourseCompetencyDefaultOption": "Wähle Startkurskompetenz", + "tailCourseCompetencyLabel": "Schlusskurskompetenz", + "tailCourseCompetencyDefaultOption": "Wähle Schlusskurskompetenz", + "relationTypeLabel": "Beziehungstyp", + "relationTypeDefaultOption": "Wähle Beziehungstyp", + "deleteRelationButtonLabel": "Beziehung löschen", + "updateRelationButtonLabel": "Beziehung aktualisieren", + "createRelationButtonLabel": "Beziehung erstellen", + "cyclicDependencyError": "Du kannst keine zyklischen Beziehungen zwischen Kurskompetenzen erstellen." + }, + "graph": { + "nodeTypes": { + "competency": "K", + "prerequisite": "V" + }, + "tooltips": { + "competency": "Dieser Knoten repräsentiert eine Kompetenz", + "prerequisite": "Dieser Knoten repräsentiert eine Voraussetzung" + } + } + }, "featureExplanation": { "title": "Einführung in Kurskompetenzen", "adaptiveLearning": { diff --git a/src/main/webapp/i18n/en/competency.json b/src/main/webapp/i18n/en/competency.json index c056c0d50d94..e4aedbe82c77 100644 --- a/src/main/webapp/i18n/en/competency.json +++ b/src/main/webapp/i18n/en/competency.json @@ -172,8 +172,9 @@ "softDueDate": "Recommended until", "masteryThreshold": "Mastery threshold", "manage": { - "helpButton": "Help", - "importAllButton": "Import all of a course" + "importAllButton": "Import all of a course", + "editRelationsButton": "Edit relations", + "helpButton": "Help" }, "importSettings": { "importRelationsLabel": "Import relations", @@ -242,6 +243,36 @@ "EVALUATE": "appraise, argue, choose, defend, judge, select, support, value, critique", "CREATE": "design, formulate, hypothesize, invent, plan, propose, write, assemble, construct, develop" }, + "relations": { + "modalTitle": "Course competency relations", + "relationTypes": { + "ASSUMES": "Assumes", + "EXTENDS": "Extends", + "MATCHES": "Matches" + }, + "form": { + "headCourseCompetencyLabel": "Head course competency", + "headCourseCompetencyDefaultOption": "Select head course competency", + "tailCourseCompetencyLabel": "Tail course competency", + "tailCourseCompetencyDefaultOption": "Select tail course competency", + "relationTypeLabel": "Relation Type", + "relationTypeDefaultOption": "Select relation type", + "deleteRelationButtonLabel": "Delete Relation", + "updateRelationButtonLabel": "Update Relation", + "createRelationButtonLabel": "Create Relation", + "cyclicDependencyError": "You cannot create a cyclic dependency between course competencies." + }, + "graph": { + "nodeTypes": { + "competency": "C", + "prerequisite": "P" + }, + "tooltips": { + "competency": "This node represents a Competency", + "prerequisite": "This node represents a Prerequisite" + } + } + }, "featureExplanation": { "title": "Introduction to course competencies", "adaptiveLearning": { diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/competency/CourseCompetencyIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/competency/CourseCompetencyIntegrationTest.java index df2c561bda7e..bccff7083c8c 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/competency/CourseCompetencyIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/competency/CourseCompetencyIntegrationTest.java @@ -27,6 +27,7 @@ import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportResponseDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyRelationDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; +import de.tum.cit.aet.artemis.atlas.dto.UpdateCourseCompetencyRelationDTO; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.CourseCompetencyProgressDTO; import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; @@ -467,6 +468,31 @@ void shouldReturnBadRequestForCircularRelations() throws Exception { request.post("/api/courses/" + course.getId() + "/course-competencies/relations", CompetencyRelationDTO.of(relation), HttpStatus.BAD_REQUEST); } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void shouldUpdateForInstructor() throws Exception { + var headCompetency = competencyUtilService.createCompetency(course); + var relationToCreate = new CompetencyRelation(); + relationToCreate.setTailCompetency(courseCompetency); + relationToCreate.setHeadCompetency(headCompetency); + relationToCreate.setType(RelationType.EXTENDS); + + request.postWithResponseBody("/api/courses/" + course.getId() + "/course-competencies/relations", CompetencyRelationDTO.of(relationToCreate), CompetencyRelation.class, + HttpStatus.OK); + + var relations = competencyRelationRepository.findAllWithHeadAndTailByCourseId(course.getId()); + assertThat(relations).hasSize(1); + var relation = relations.stream().findFirst().get(); + assertThat(relation.getType()).isEqualTo(RelationType.EXTENDS); + + request.patch("/api/courses/" + course.getId() + "/course-competencies/relations/" + relation.getId(), new UpdateCourseCompetencyRelationDTO(RelationType.MATCHES), + HttpStatus.NO_CONTENT); + + relations = competencyRelationRepository.findAllWithHeadAndTailByCourseId(course.getId()); + assertThat(relations).hasSize(1); + assertThat(relations.stream().findFirst().get().getType()).isEqualTo(RelationType.MATCHES); + } } @Test diff --git a/src/test/javascript/spec/component/competencies/competency-management/competency-management-table.component.spec.ts b/src/test/javascript/spec/component/competencies/competency-management/competency-management-table.component.spec.ts index d38ba571bcdd..e15cefdbc52d 100644 --- a/src/test/javascript/spec/component/competencies/competency-management/competency-management-table.component.spec.ts +++ b/src/test/javascript/spec/component/competencies/competency-management/competency-management-table.component.spec.ts @@ -53,7 +53,6 @@ describe('CompetencyManagementTableComponent', () => { it('should handle import all data', () => { component.courseCompetencies = []; - component.relations = []; const responseBody: CompetencyWithTailRelationDTO[] = [ { competency: { id: 1 }, tailRelations: [] }, @@ -62,7 +61,6 @@ describe('CompetencyManagementTableComponent', () => { component.updateDataAfterImportAll(responseBody); expect(component.courseCompetencies).toHaveLength(2); - expect(component.relations).toHaveLength(1); }); it('should handle delete competency', () => { @@ -72,7 +70,6 @@ describe('CompetencyManagementTableComponent', () => { const competency2 = { id: 2, type: CourseCompetencyType.COMPETENCY }; component.service = competencyService; component.courseCompetencies = [competency1, competency2]; - component.relations = [{ id: 1, headCompetency: competency1, tailCompetency: competency2, type: CompetencyRelationType.ASSUMES }]; component.deleteCompetency(1); expect(deleteSpy).toHaveBeenCalledOnce(); diff --git a/src/test/javascript/spec/component/competencies/competency-management/competency-management.component.spec.ts b/src/test/javascript/spec/component/competencies/competency-management/competency-management.component.spec.ts index b0305c56b76d..a21a670a38fa 100644 --- a/src/test/javascript/spec/component/competencies/competency-management/competency-management.component.spec.ts +++ b/src/test/javascript/spec/component/competencies/competency-management/competency-management.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; -import { Competency, CompetencyRelation, CompetencyWithTailRelationDTO, CourseCompetencyProgress, CourseCompetencyType } from 'app/entities/competency.model'; +import { Competency, CompetencyWithTailRelationDTO, CourseCompetencyProgress, CourseCompetencyType } from 'app/entities/competency.model'; import { CompetencyManagementComponent } from 'app/course/competencies/competency-management/competency-management.component'; import { ActivatedRoute, provideRouter } from '@angular/router'; import { DeleteButtonDirective } from 'app/shared/delete-dialog/delete-button.directive'; @@ -24,7 +24,6 @@ import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.serv import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; import { IrisCourseSettings } from 'app/entities/iris/settings/iris-settings.model'; import { PROFILE_IRIS } from 'app/app.constants'; -import { CompetencyRelationGraphStubComponent } from './competency-relation-graph-stub.component'; import { Prerequisite } from 'app/entities/prerequisite.model'; import { CompetencyManagementTableComponent } from 'app/course/competencies/competency-management/competency-management-table.component'; import { CourseCompetencyApiService } from 'app/course/competencies/services/course-competency-api.service'; @@ -42,7 +41,6 @@ describe('CompetencyManagementComponent', () => { let modalService: NgbModal; let getAllForCourseSpy: any; - let getCompetencyRelationsSpy: any; beforeEach(() => { TestBed.configureTestingModule({ @@ -50,7 +48,6 @@ describe('CompetencyManagementComponent', () => { declarations: [ CompetencyManagementComponent, MockHasAnyAuthorityDirective, - CompetencyRelationGraphStubComponent, MockComponent(DocumentationButtonComponent), MockComponent(ImportAllCompetenciesComponent), MockComponent(CompetencyManagementTableComponent), @@ -103,7 +100,6 @@ describe('CompetencyManagementComponent', () => { type: CourseCompetencyType.PREREQUISITE, } as Prerequisite, ]); - getCompetencyRelationsSpy = jest.spyOn(courseCompetencyApiService, 'getCourseCompetencyRelations').mockResolvedValue([{ id: 1 } as CompetencyRelation]); }); }); @@ -139,7 +135,6 @@ describe('CompetencyManagementComponent', () => { await component.loadData(); expect(getAllForCourseSpy).toHaveBeenCalledOnce(); - expect(getCompetencyRelationsSpy).toHaveBeenCalledOnce(); expect(component.competencies).toHaveLength(2); expect(component.prerequisites).toHaveLength(1); @@ -170,7 +165,6 @@ describe('CompetencyManagementComponent', () => { jest.spyOn(courseCompetencyApiService, 'importAllByCourseId').mockResolvedValue(importedCompetencies); await component.loadData(); const existingCompetencies = component.competencies.length; - const existingRelations = component.relations.length; const importButton = fixture.debugElement.query(By.css('#courseCompetencyImportAllButton')); importButton.nativeElement.click(); @@ -184,80 +178,5 @@ describe('CompetencyManagementComponent', () => { await fixture.whenStable(); expect(component.competencies).toHaveLength(existingCompetencies + 2); - expect(component.relations).toHaveLength(existingRelations + 1); - }); - - it('should handle create relation callback', async () => { - const relation: CompetencyRelation = { id: 1 }; - jest.spyOn(courseCompetencyApiService, 'createCourseCompetencyRelation').mockResolvedValue(relation); - - fixture.detectChanges(); - await fixture.whenStable(); - - const existingRelations = component.relations.length; - - const relationGraph: CompetencyRelationGraphStubComponent = fixture.debugElement.query(By.directive(CompetencyRelationGraphStubComponent)).componentInstance; - expect(relationGraph).toBeDefined(); - relationGraph.onCreateRelation.emit(relation); - - fixture.detectChanges(); - await fixture.whenStable(); - - expect(component.relations).toHaveLength(existingRelations + 1); - }); - - it('should handle remove relation callback', () => { - const modalRef = { - result: Promise.resolve(), - componentInstance: {}, - } as NgbModalRef; - jest.spyOn(modalService, 'open').mockReturnValue(modalRef); - - fixture.detectChanges(); - - const relationGraph: CompetencyRelationGraphStubComponent = fixture.debugElement.query(By.directive(CompetencyRelationGraphStubComponent)).componentInstance; - relationGraph.onRemoveRelation.emit(1); - fixture.detectChanges(); - - expect(modalService.open).toHaveBeenCalledOnce(); - }); - - it('should remove relation', async () => { - jest.spyOn(courseCompetencyApiService, 'deleteCourseCompetencyRelation').mockResolvedValue(); - fixture.detectChanges(); - component.relations = [{ id: 1, headCompetency: { id: 5 }, tailCompetency: { id: 3 } }]; - - fixture.detectChanges(); - await fixture.whenStable(); - fixture.detectChanges(); - - expect(component.relations).toHaveLength(1); - - await component['removeRelation'](1); - - expect(component.relations).toHaveLength(0); - }); - - it('should remove competency and its relation', () => { - component.competencies = [ - { id: 1, type: CourseCompetencyType.COMPETENCY }, - { id: 2, type: CourseCompetencyType.COMPETENCY }, - ]; - component.prerequisites = [{ id: 3, type: CourseCompetencyType.PREREQUISITE }]; - component.courseCompetencies = component.competencies.concat(component.prerequisites); - component.relations = [ - { id: 1, tailCompetency: component.competencies.first(), headCompetency: component.competencies.last() }, - { id: 2, tailCompetency: component.competencies.last(), headCompetency: component.prerequisites.first() }, - { id: 3, tailCompetency: component.prerequisites.first(), headCompetency: component.competencies.first() }, - ]; - - component.onRemoveCompetency(2); - - expect(component.relations).toHaveLength(1); - expect(component.relations.first()?.id).toBe(3); - expect(component.competencies).toHaveLength(1); - expect(component.competencies.first()?.id).toBe(1); - expect(component.prerequisites).toHaveLength(1); - expect(component.prerequisites.first()?.id).toBe(3); }); }); diff --git a/src/test/javascript/spec/component/competencies/competency-management/competency-relation-graph-stub.component.ts b/src/test/javascript/spec/component/competencies/competency-management/competency-relation-graph-stub.component.ts deleted file mode 100644 index af5d302a6e1f..000000000000 --- a/src/test/javascript/spec/component/competencies/competency-management/competency-relation-graph-stub.component.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Component, EventEmitter, Output, input } from '@angular/core'; -import { Competency, CompetencyRelation } from 'app/entities/competency.model'; - -@Component({ - selector: 'jhi-competency-relation-graph', - template: '', -}) -export class CompetencyRelationGraphStubComponent { - competencies = input([]); - relations = input([]); - - @Output() onRemoveRelation = new EventEmitter(); - @Output() onCreateRelation = new EventEmitter(); -} diff --git a/src/test/javascript/spec/component/competencies/competency-management/competency-relation-graph.component.spec.ts b/src/test/javascript/spec/component/competencies/competency-management/competency-relation-graph.component.spec.ts deleted file mode 100644 index c60016292749..000000000000 --- a/src/test/javascript/spec/component/competencies/competency-management/competency-relation-graph.component.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { ArtemisTestModule } from '../../../test.module'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MockDirective, MockPipe } from 'ng-mocks'; -import { Component } from '@angular/core'; -import { Competency, CompetencyRelation, CompetencyRelationError, CompetencyRelationType } from 'app/entities/competency.model'; -import { CompetencyRelationGraphComponent } from 'app/course/competencies/competency-management/competency-relation-graph.component'; -import { NgbAccordionBody, NgbAccordionButton, NgbAccordionCollapse, NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem } from '@ng-bootstrap/ng-bootstrap'; -import { Edge, Node } from '@swimlane/ngx-graph'; - -// eslint-disable-next-line @angular-eslint/component-selector -@Component({ selector: 'ngx-graph', template: '' }) -class NgxGraphStubComponent {} - -describe('CompetencyRelationGraphComponent', () => { - let componentFixture: ComponentFixture; - let component: CompetencyRelationGraphComponent; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ArtemisTestModule], - declarations: [ - CompetencyRelationGraphComponent, - MockPipe(ArtemisTranslatePipe), - MockDirective(NgbAccordionDirective), - MockDirective(NgbAccordionItem), - MockDirective(NgbAccordionHeader), - MockDirective(NgbAccordionButton), - MockDirective(NgbAccordionCollapse), - MockDirective(NgbAccordionBody), - NgxGraphStubComponent, - ], - providers: [], - }) - .compileComponents() - .then(() => { - componentFixture = TestBed.createComponent(CompetencyRelationGraphComponent); - component = componentFixture.componentInstance; - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should initialize', () => { - componentFixture.detectChanges(); - expect(component).toBeDefined(); - }); - - it('should correctly update nodes and edges from input', () => { - componentFixture.componentRef.setInput('competencies', [createCompetency(1, 'Competency 1'), createCompetency(2, 'Competency 2')]); - componentFixture.componentRef.setInput('relations', [createRelation(1, 1, 2, CompetencyRelationType.EXTENDS)]); - componentFixture.detectChanges(); - - const expectedNodes: Node[] = [ - { id: '1', label: 'Competency 1' }, - { id: '2', label: 'Competency 2' }, - ]; - const expectedEdges: Edge[] = [{ id: 'edge1', source: '1', target: '2', label: CompetencyRelationType.EXTENDS, data: { id: 1 } }]; - - expect(component.nodes()).toEqual(expectedNodes); - expect(component.edges()).toEqual(expectedEdges); - }); - - it('should create competency relation', () => { - componentFixture.componentRef.setInput('competencies', [createCompetency(1, 'Competency 1'), createCompetency(2, 'Competency 2')]); - component.tailCompetencyId = 1; - component.headCompetencyId = 2; - component.relationType = CompetencyRelationType.ASSUMES; - const relation: CompetencyRelation = { - tailCompetency: { id: component.tailCompetencyId }, - headCompetency: { id: component.headCompetencyId }, - type: component.relationType, - }; - const onCreateRelationSpy = jest.spyOn(component.onCreateRelation, 'emit'); - - componentFixture.detectChanges(); - - component.createRelation(); - expect(onCreateRelationSpy).toHaveBeenCalledWith(relation); - }); - - it('should not competency relation on error', () => { - component.tailCompetencyId = 1; - component.headCompetencyId = 1; - component.relationType = CompetencyRelationType.ASSUMES; - const onCreateRelationSpy = jest.spyOn(component.onCreateRelation, 'emit'); - const validateSpy = jest.spyOn(component, 'validate'); - - component.createRelation(); - expect(onCreateRelationSpy).not.toHaveBeenCalled(); - expect(validateSpy).toHaveBeenCalledOnce(); - }); - - it('should remove competency relation', () => { - const onRemoveRelationSpy = jest.spyOn(component.onRemoveRelation, 'emit'); - - component.removeRelation({ source: '123', data: { id: 456 } } as Edge); - expect(onRemoveRelationSpy).toHaveBeenCalledWith(456); - }); - - it('should detect circles on relations', () => { - const competencies = [createCompetency(16, '16'), createCompetency(17, '17'), createCompetency(18, '18')]; - const relations = [createRelation(1, 16, 17, CompetencyRelationType.EXTENDS), createRelation(1, 17, 18, CompetencyRelationType.MATCHES)]; - componentFixture.componentRef.setInput('competencies', competencies); - componentFixture.componentRef.setInput('relations', relations); - componentFixture.detectChanges(); - - component.tailCompetencyId = 18; - component.headCompetencyId = 16; - component.relationType = CompetencyRelationType.ASSUMES; - - component.validate(); - - expect(component.relationError).toBe(CompetencyRelationError.CIRCULAR); - }); - - it('should not detect circles on arbitrary relations', () => { - const competencies = [createCompetency(16, '16'), createCompetency(17, '17')]; - const relations: CompetencyRelation[] = []; - componentFixture.componentRef.setInput('competencies', competencies); - componentFixture.componentRef.setInput('relations', relations); - componentFixture.detectChanges(); - - component.tailCompetencyId = 17; - component.headCompetencyId = 16; - component.relationType = CompetencyRelationType.ASSUMES; - - component.validate(); - - expect(component.relationError).toBeUndefined(); - }); - - it('should prevent creating already existing relations', () => { - const competencies = [createCompetency(16, '16'), createCompetency(17, '17')]; - const relations = [createRelation(1, 16, 17, CompetencyRelationType.EXTENDS)]; - componentFixture.componentRef.setInput('competencies', competencies); - componentFixture.componentRef.setInput('relations', relations); - componentFixture.detectChanges(); - - component.tailCompetencyId = 16; - component.headCompetencyId = 17; - component.relationType = CompetencyRelationType.EXTENDS; - - component.validate(); - - expect(component.relationError).toBe(CompetencyRelationError.EXISTING); - }); - - it('should prevent creating self relations', () => { - const competencies = [createCompetency(16, '16'), createCompetency(17, '17')]; - const relations: CompetencyRelation[] = []; - componentFixture.componentRef.setInput('competencies', competencies); - componentFixture.componentRef.setInput('relations', relations); - componentFixture.detectChanges(); - - component.tailCompetencyId = 16; - component.headCompetencyId = 16; - component.relationType = CompetencyRelationType.EXTENDS; - - component.validate(); - - expect(component.relationError).toBe(CompetencyRelationError.SELF); - }); - - it('should zoom to fit and center on centerView', () => { - const zoomToFitStub = jest.spyOn(component.zoomToFit$, 'next'); - const centerStub = jest.spyOn(component.center$, 'next'); - componentFixture.detectChanges(); - component.centerView(); - expect(zoomToFitStub).toHaveBeenCalledExactlyOnceWith({ autoCenter: true }); - expect(centerStub).toHaveBeenCalledExactlyOnceWith(true); - }); - - function createCompetency(id: number, title: string) { - const competency: Competency = { - id: id, - title: title, - }; - return competency; - } - - function createRelation(id: number, tailCompetencyId: number, headCompetencyId: number, relationType: CompetencyRelationType) { - const relation: CompetencyRelation = { - id: id, - tailCompetency: { id: tailCompetencyId }, - headCompetency: { id: headCompetencyId }, - type: relationType, - }; - return relation; - } -}); diff --git a/src/test/javascript/spec/component/competencies/components/course-competencies-relation-graph.component.spec.ts b/src/test/javascript/spec/component/competencies/components/course-competencies-relation-graph.component.spec.ts new file mode 100644 index 000000000000..561f3ae5bd07 --- /dev/null +++ b/src/test/javascript/spec/component/competencies/components/course-competencies-relation-graph.component.spec.ts @@ -0,0 +1,93 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { CourseCompetenciesRelationGraphComponent } from 'app/course/competencies/components/course-competencies-relation-graph/course-competencies-relation-graph.component'; +import { CompetencyRelationDTO, CompetencyRelationType, CourseCompetency, CourseCompetencyType } from 'app/entities/competency.model'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { TranslateService } from '@ngx-translate/core'; + +describe('CourseCompetenciesRelationGraphComponent', () => { + let component: CourseCompetenciesRelationGraphComponent; + let fixture: ComponentFixture; + + const courseCompetencies: CourseCompetency[] = [ + { id: 1, type: CourseCompetencyType.COMPETENCY, title: 'Competency' }, + { id: 2, type: CourseCompetencyType.PREREQUISITE, title: 'Prerequisite' }, + ]; + + const relations: CompetencyRelationDTO[] = [ + { + id: 1, + relationType: CompetencyRelationType.EXTENDS, + tailCompetencyId: 1, + headCompetencyId: 2, + }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CourseCompetenciesRelationGraphComponent, NoopAnimationsModule], + providers: [ + { + provide: TranslateService, + useClass: MockTranslateService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CourseCompetenciesRelationGraphComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput('courseCompetencies', courseCompetencies); + fixture.componentRef.setInput('relations', relations); + fixture.componentRef.setInput('selectedRelationId', 1); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should initialize', async () => { + expect(component).toBeDefined(); + expect(component.courseCompetencies()).toEqual(courseCompetencies); + expect(component.relations()).toEqual(relations); + }); + + it('should map edges correctly', () => { + expect(component.edges()).toEqual( + relations.map((relation) => { + return { + id: 'edge-' + relation.id!.toString(), + source: relation.headCompetencyId!.toString(), + target: relation.tailCompetencyId!.toString(), + label: relation.relationType, + data: { + id: relation.id, + }, + }; + }), + ); + }); + + it('should map nodes correctly', () => { + fixture.detectChanges(); + + expect(component.nodes()).toEqual( + courseCompetencies.map((cc) => { + return { + id: cc.id!.toString(), + label: cc.title, + data: { + id: cc.id, + type: cc.type, + }, + }; + }), + ); + }); + + it('should update node dimension', () => { + fixture.detectChanges(); + component['setNodeDimension']({ id: '1', dimension: { width: 0, height: 45.59 } }); + expect(component.nodes().find((node) => node.id === '1')?.dimension).toEqual({ width: 0, height: 45.59 }); + }); +}); diff --git a/src/test/javascript/spec/component/competencies/components/course-competencies-relation-modal.component.spec.ts b/src/test/javascript/spec/component/competencies/components/course-competencies-relation-modal.component.spec.ts new file mode 100644 index 000000000000..a3a99fd7a25e --- /dev/null +++ b/src/test/javascript/spec/component/competencies/components/course-competencies-relation-modal.component.spec.ts @@ -0,0 +1,129 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CourseCompetenciesRelationModalComponent } from 'app/course/competencies/components/course-competencies-relation-modal/course-competencies-relation-modal.component'; +import { CourseCompetencyApiService } from 'app/course/competencies/services/course-competency-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { CompetencyRelationDTO, CompetencyRelationType, CourseCompetency, CourseCompetencyType } from 'app/entities/competency.model'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { MockNgbActiveModalService } from '../../../helpers/mocks/service/mock-ngb-active-modal.service'; +import { TranslateService } from '@ngx-translate/core'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +describe('CourseCompetenciesRelationModalComponent', () => { + let component: CourseCompetenciesRelationModalComponent; + let fixture: ComponentFixture; + let courseCompetencyApiService: CourseCompetencyApiService; + let alertService: AlertService; + let activeModal: NgbActiveModal; + + const courseId = 1; + const courseCompetencies: CourseCompetency[] = [ + { id: 1, type: CourseCompetencyType.COMPETENCY }, + { id: 2, type: CourseCompetencyType.PREREQUISITE }, + ]; + const relations: CompetencyRelationDTO[] = [ + { + id: 1, + relationType: CompetencyRelationType.EXTENDS, + tailCompetencyId: 1, + headCompetencyId: 2, + }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CourseCompetenciesRelationModalComponent, NoopAnimationsModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: NgbActiveModal, + useClass: MockNgbActiveModalService, + }, + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { + provide: AlertService, + useClass: MockAlertService, + }, + { + provide: CourseCompetencyApiService, + useValue: { + getCourseCompetencyRelationsByCourseId: jest.fn(), + }, + }, + ], + }).compileComponents(); + + courseCompetencyApiService = TestBed.inject(CourseCompetencyApiService); + alertService = TestBed.inject(AlertService); + activeModal = TestBed.inject(NgbActiveModal); + + fixture = TestBed.createComponent(CourseCompetenciesRelationModalComponent); + component = fixture.componentInstance; + + jest.spyOn(courseCompetencyApiService, 'getCourseCompetencyRelationsByCourseId').mockResolvedValue(relations); + + fixture.componentRef.setInput('courseId', courseId); + fixture.componentRef.setInput('courseCompetencies', courseCompetencies); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize', () => { + expect(component).toBeTruthy(); + }); + + it('should load relations', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.relations()).toEqual(relations); + }); + + it('should show alert on error', async () => { + const errorSpy = jest.spyOn(alertService, 'addAlert'); + jest.spyOn(courseCompetencyApiService, 'getCourseCompetencyRelationsByCourseId').mockReturnValue(Promise.reject(new Error('Error'))); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(errorSpy).toHaveBeenCalledOnce(); + }); + + it('should set isLoading correctly', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should closeModal', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + + component['closeModal'](); + + expect(closeSpy).toHaveBeenCalledOnce(); + }); + + it('should call selectCourseCompetency on courseCompetencyRelationFormComponent with valid courseCompetencyId', () => { + fixture.detectChanges(); + + const courseCompetencyId = 1; + const selectSpy = jest.spyOn(component['courseCompetencyRelationFormComponent'](), 'selectCourseCompetency'); + + component['selectCourseCompetency'](courseCompetencyId); + + expect(selectSpy).toHaveBeenCalledExactlyOnceWith(courseCompetencyId); + }); +}); diff --git a/src/test/javascript/spec/component/competencies/components/course-competency-relation-form.component.spec.ts b/src/test/javascript/spec/component/competencies/components/course-competency-relation-form.component.spec.ts new file mode 100644 index 000000000000..aed1e03612f8 --- /dev/null +++ b/src/test/javascript/spec/component/competencies/components/course-competency-relation-form.component.spec.ts @@ -0,0 +1,364 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CourseCompetencyRelationFormComponent, UnionFind } from 'app/course/competencies/components/course-competency-relation-form/course-competency-relation-form.component'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { CourseCompetencyApiService } from 'app/course/competencies/services/course-competency-api.service'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { TranslateService } from '@ngx-translate/core'; +import { CompetencyRelationDTO, CompetencyRelationType, CourseCompetency, UpdateCourseCompetencyRelationDTO } from 'app/entities/competency.model'; + +describe('CourseCompetencyRelationFormComponent', () => { + let component: CourseCompetencyRelationFormComponent; + let fixture: ComponentFixture; + let courseCompetencyApiService: CourseCompetencyApiService; + let alertService: AlertService; + + let createCourseCompetencyRelationSpy: jest.SpyInstance; + let updateCourseCompetencyRelationSpy: jest.SpyInstance; + let deleteCourseCompetencyRelationSpy: jest.SpyInstance; + + const courseId = 1; + const courseCompetencies: CourseCompetency[] = [ + { id: 1, title: 'Competency 1' }, + { id: 2, title: 'Competency 2' }, + { id: 3, title: 'Competency 3' }, + ]; + const relations: CompetencyRelationDTO[] = [ + { + id: 1, + tailCompetencyId: 1, + headCompetencyId: 2, + relationType: CompetencyRelationType.EXTENDS, + }, + ]; + const selectedRelationId = 1; + + const newRelation = { + id: 2, + headCompetencyId: 2, + tailCompetencyId: 3, + relationType: CompetencyRelationType.EXTENDS, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CourseCompetencyRelationFormComponent], + providers: [ + { + provide: AlertService, + useClass: MockAlertService, + }, + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { + provide: CourseCompetencyApiService, + useValue: { + createCourseCompetencyRelation: jest.fn(), + updateCourseCompetencyRelation: jest.fn(), + deleteCourseCompetencyRelation: jest.fn(), + }, + }, + ], + }).compileComponents(); + + courseCompetencyApiService = TestBed.inject(CourseCompetencyApiService); + alertService = TestBed.inject(AlertService); + + createCourseCompetencyRelationSpy = jest.spyOn(courseCompetencyApiService, 'createCourseCompetencyRelation').mockResolvedValue(newRelation); + updateCourseCompetencyRelationSpy = jest.spyOn(courseCompetencyApiService, 'updateCourseCompetencyRelation').mockResolvedValue(); + deleteCourseCompetencyRelationSpy = jest.spyOn(courseCompetencyApiService, 'deleteCourseCompetencyRelation').mockResolvedValue(); + + fixture = TestBed.createComponent(CourseCompetencyRelationFormComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput('courseId', courseId); + fixture.componentRef.setInput('courseCompetencies', courseCompetencies); + fixture.componentRef.setInput('relations', relations); + fixture.componentRef.setInput('selectedRelationId', undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should set relationAlreadyExists correctly', () => { + component.headCompetencyId.set(2); + component.tailCompetencyId.set(1); + component.relationType.set(CompetencyRelationType.ASSUMES); + + fixture.detectChanges(); + + expect(component.relationAlreadyExists()).toBeTrue(); + }); + + it('should set exactRelationAlreadyExists correctly', () => { + component.headCompetencyId.set(2); + component.tailCompetencyId.set(1); + component.relationType.set(CompetencyRelationType.EXTENDS); + + fixture.detectChanges(); + + expect(component.exactRelationAlreadyExists()).toBeTrue(); + }); + + it('should select relation if selectedRelationId is set', () => { + fixture.componentRef.setInput('selectedRelationId', selectedRelationId); + + fixture.detectChanges(); + + expect(component.headCompetencyId()).toBe(2); + expect(component.tailCompetencyId()).toBe(1); + expect(component.relationType()).toBe(CompetencyRelationType.EXTENDS); + }); + + it('should set headCompetencyId if it is undefined', () => { + component.headCompetencyId.set(undefined); + component.tailCompetencyId.set(2); + + component.selectCourseCompetency(1); + + expect(component.headCompetencyId()).toBe(1); + expect(component.tailCompetencyId()).toBeUndefined(); + }); + + it('should set tailCompetencyId if headCompetencyId is defined and tailCompetencyId is undefined', () => { + component.headCompetencyId.set(1); + component.tailCompetencyId.set(undefined); + + component.selectCourseCompetency(2); + + expect(component.tailCompetencyId()).toBe(2); + }); + + it('should reset headCompetencyId if both headCompetencyId and tailCompetencyId are defined', () => { + component.headCompetencyId.set(1); + component.tailCompetencyId.set(2); + + component.selectCourseCompetency(3); + + expect(component.headCompetencyId()).toBe(3); + expect(component.tailCompetencyId()).toBeUndefined(); + }); + + it('should create relation', async () => { + component.headCompetencyId.set(2); + component.tailCompetencyId.set(3); + component.relationType.set(CompetencyRelationType.EXTENDS); + + await component['createRelation'](); + + expect(createCourseCompetencyRelationSpy).toHaveBeenCalledExactlyOnceWith(courseId, { + headCompetencyId: 2, + tailCompetencyId: 3, + relationType: CompetencyRelationType.EXTENDS, + }); + expect(component.headCompetencyId()).toBe(2); + expect(component.tailCompetencyId()).toBe(3); + expect(component.relationType()).toBe(CompetencyRelationType.EXTENDS); + expect(component.selectedRelationId()).toBe(2); + expect(component.relations()).toEqual([...relations, newRelation]); + }); + + it('should set isLoading correctly when creating a relation', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + component.headCompetencyId.set(2); + component.tailCompetencyId.set(3); + component.relationType.set(CompetencyRelationType.EXTENDS); + + await component['createRelation'](); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should show error when creating relation fails', async () => { + const error = 'Error creating relation'; + createCourseCompetencyRelationSpy.mockRejectedValue(error); + const alertServiceErrorSpy = jest.spyOn(alertService, 'error'); + + component.headCompetencyId.set(2); + component.tailCompetencyId.set(3); + component.relationType.set(CompetencyRelationType.EXTENDS); + + await component['createRelation'](); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); + + it('should update relation', async () => { + fixture.componentRef.setInput('selectedRelationId', selectedRelationId); + + fixture.detectChanges(); + + component.relationType.set(CompetencyRelationType.ASSUMES); + + await component['updateRelation'](); + + expect(updateCourseCompetencyRelationSpy).toHaveBeenCalledExactlyOnceWith(courseId, selectedRelationId, { + newRelationType: CompetencyRelationType.ASSUMES, + }); + const newRelations = [...relations].map((relation) => { + if (relation.id === selectedRelationId) { + return { ...relation, relationType: CompetencyRelationType.ASSUMES }; + } + return relation; + }); + expect(component.relations()).toEqual(newRelations); + }); + + it('should set isLoading correctly when updating a relation', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + fixture.componentRef.setInput('selectedRelationId', selectedRelationId); + + fixture.detectChanges(); + + component.relationType.set(CompetencyRelationType.ASSUMES); + + await component['updateRelation'](); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should show error when updating relation fails', async () => { + updateCourseCompetencyRelationSpy.mockRejectedValue('Error updating relation'); + const alertServiceErrorSpy = jest.spyOn(alertService, 'error'); + fixture.componentRef.setInput('selectedRelationId', selectedRelationId); + + fixture.detectChanges(); + + component.relationType.set(CompetencyRelationType.ASSUMES); + + await component['updateRelation'](); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); + + it('should select head course competency', () => { + component['selectHeadCourseCompetency'](2); + + expect(component.headCompetencyId()).toBe(2); + expect(component.tailCompetencyId()).toBeUndefined(); + expect(component.selectedRelationId()).toBeUndefined(); + }); + + it('should set tailCompetencyId and selectedRelationId when an existing relation is found', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const tailId = 1; + component.headCompetencyId.set(2); + component.relationType.set(CompetencyRelationType.EXTENDS); + + component['selectTailCourseCompetency'](tailId); + + expect(component.tailCompetencyId()).toBe(1); + expect(component.selectedRelationId()).toBe(1); + }); + + it('should set tailCompetencyId and clear selectedRelationId when no existing relation is found', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const tailId = 2; + component.headCompetencyId.set(3); + component.relationType.set(CompetencyRelationType.EXTENDS); + + component['selectTailCourseCompetency'](tailId); + + expect(component.tailCompetencyId()).toBe(2); + expect(component.selectedRelationId()).toBeUndefined(); + }); + + it('should not allow to create circular dependencies', () => { + component.headCompetencyId.set(1); + component.tailCompetencyId.set(1); + component.relationType.set(CompetencyRelationType.EXTENDS); + + expect(component['selectableTailCourseCompetencyIds']).not.toContain(1); + expect(component.showCircularDependencyError()).toBeTrue(); + }); + + it('should delete relation', async () => { + fixture.componentRef.setInput('selectedRelationId', selectedRelationId); + + fixture.detectChanges(); + + await component['deleteRelation'](); + + expect(deleteCourseCompetencyRelationSpy).toHaveBeenCalledExactlyOnceWith(courseId, selectedRelationId); + expect(component.relations()).toHaveLength(relations.length - 1); + }); + + it('should set isLoading correctly when deleting a relation', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + fixture.componentRef.setInput('selectedRelationId', selectedRelationId); + + fixture.detectChanges(); + + await component['deleteRelation'](); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should show error when deleting relation fails', async () => { + deleteCourseCompetencyRelationSpy.mockRejectedValue('Error deleting relation'); + const alertServiceErrorSpy = jest.spyOn(alertService, 'error'); + fixture.componentRef.setInput('selectedRelationId', selectedRelationId); + + fixture.detectChanges(); + + await component['deleteRelation'](); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); +}); + +describe('UnionFind', () => { + let unionFind: UnionFind; + + beforeEach(() => { + unionFind = new UnionFind(5); + }); + + it('should initialize parent and rank arrays correctly', () => { + expect(unionFind.parent).toEqual([0, 1, 2, 3, 4]); + expect(unionFind.rank).toEqual([1, 1, 1, 1, 1]); + }); + + it('should find the representative of a set', () => { + expect(unionFind.find(0)).toBe(0); + expect(unionFind.find(1)).toBe(1); + }); + + it('should perform union by rank correctly', () => { + unionFind.union(0, 1); + expect(unionFind.find(0)).toBe(unionFind.find(1)); + }); + + it('should perform path compression correctly', () => { + unionFind.union(0, 1); + unionFind.union(1, 2); + expect(unionFind.find(2)).toBe(0); + expect(unionFind.parent[2]).toBe(0); + }); + + it('should handle union of already connected components', () => { + unionFind.union(0, 1); + unionFind.union(1, 2); + unionFind.union(0, 2); + expect(unionFind.find(2)).toBe(0); + }); + + it('should handle union of components with equal rank', () => { + unionFind.union(0, 1); + unionFind.union(2, 3); + unionFind.union(1, 2); + expect(unionFind.find(3)).toBe(0); + expect(unionFind.rank[0]).toBe(3); // Corrected expected rank value + }); +}); diff --git a/src/test/javascript/spec/component/competencies/components/course-competency-relation-node.component.spec.ts b/src/test/javascript/spec/component/competencies/components/course-competency-relation-node.component.spec.ts new file mode 100644 index 000000000000..a44fb57502e2 --- /dev/null +++ b/src/test/javascript/spec/component/competencies/components/course-competency-relation-node.component.spec.ts @@ -0,0 +1,50 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CourseCompetencyType } from 'app/entities/competency.model'; +import { Node } from '@swimlane/ngx-graph'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { TranslateService } from '@ngx-translate/core'; +import { CourseCompetencyRelationNodeComponent } from 'app/course/competencies/components/course-competency-relation-node/course-competency-relation-node.component'; + +describe('CourseCompetencyRelationNodeComponent', () => { + let component: CourseCompetencyRelationNodeComponent; + let fixture: ComponentFixture; + + const node: Node = { + id: '1', + label: 'Competency 1', + data: { + type: CourseCompetencyType.COMPETENCY, + }, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CourseCompetencyRelationNodeComponent], + providers: [ + { + provide: TranslateService, + useClass: MockTranslateService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CourseCompetencyRelationNodeComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput('courseCompetencyNode', node); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should initialize and emit size update', async () => { + const sizeUpdateEmitSpy = jest.spyOn(component.onSizeSet, 'emit'); + + fixture.detectChanges(); + + expect(component).toBeTruthy(); + expect(component.courseCompetencyNode()).toEqual(node); + expect(sizeUpdateEmitSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/competencies/components/import-course-competencies-modal/import-all-course-competencies-modal.component.spec.ts b/src/test/javascript/spec/component/competencies/components/import-all-course-competencies-modal.component.spec.ts similarity index 96% rename from src/test/javascript/spec/component/competencies/components/import-course-competencies-modal/import-all-course-competencies-modal.component.spec.ts rename to src/test/javascript/spec/component/competencies/components/import-all-course-competencies-modal.component.spec.ts index eb4ecd81c175..40e81a50cf6f 100644 --- a/src/test/javascript/spec/component/competencies/components/import-course-competencies-modal/import-all-course-competencies-modal.component.spec.ts +++ b/src/test/javascript/spec/component/competencies/components/import-all-course-competencies-modal.component.spec.ts @@ -2,7 +2,7 @@ import '@angular/localize/init'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ImportAllCourseCompetenciesModalComponent } from 'app/course/competencies/components/import-all-course-competencies-modal/import-all-course-competencies-modal.component'; -import { MockTranslateService } from '../../../../helpers/mocks/service/mock-translate.service'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; import { TranslateService } from '@ngx-translate/core'; import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -71,7 +71,7 @@ describe('ImportAllCourseCompetenciesModalComponent', () => { const course = { id: 2, title: 'Course 02', shortName: 'C2' }; component.selectCourse(course); - expect(closeModalSpy).toHaveBeenCalledWith({ + expect(closeModalSpy).toHaveBeenCalledExactlyOnceWith({ course: course, courseCompetencyImportOptions: { sourceCourseId: course.id, diff --git a/src/test/javascript/spec/component/competencies/components/import-course-competencies-settings/import-course-competencies-settings.component.spec.ts b/src/test/javascript/spec/component/competencies/components/import-course-competencies-settings.component.spec.ts similarity index 98% rename from src/test/javascript/spec/component/competencies/components/import-course-competencies-settings/import-course-competencies-settings.component.spec.ts rename to src/test/javascript/spec/component/competencies/components/import-course-competencies-settings.component.spec.ts index 1d9d6356c01a..3777ba7b1a3d 100644 --- a/src/test/javascript/spec/component/competencies/components/import-course-competencies-settings/import-course-competencies-settings.component.spec.ts +++ b/src/test/javascript/spec/component/competencies/components/import-course-competencies-settings.component.spec.ts @@ -3,7 +3,7 @@ import { CourseCompetencyImportSettings, ImportCourseCompetenciesSettingsComponent, } from 'app/course/competencies/components/import-course-competencies-settings/import-course-competencies-settings.component'; -import { MockTranslateService } from '../../../../helpers/mocks/service/mock-translate.service'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; import { TranslateService } from '@ngx-translate/core'; describe('ImportCourseCompetenciesSettingsComponent', () => { diff --git a/src/test/javascript/spec/service/course-competency/course-competency-api.service.spec.ts b/src/test/javascript/spec/service/course-competency/course-competency-api.service.spec.ts index e641f31f9ae7..fc33eb3fff62 100644 --- a/src/test/javascript/spec/service/course-competency/course-competency-api.service.spec.ts +++ b/src/test/javascript/spec/service/course-competency/course-competency-api.service.spec.ts @@ -2,7 +2,7 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { CourseCompetencyApiService } from 'app/course/competencies/services/course-competency-api.service'; -import { CompetencyRelation, CourseCompetencyImportOptionsDTO } from 'app/entities/competency.model'; +import { CompetencyRelation, CompetencyRelationType, CourseCompetencyImportOptionsDTO, UpdateCourseCompetencyRelationDTO } from 'app/entities/competency.model'; describe('CourseCompetencyApiService', () => { let httpClient: HttpTestingController; @@ -42,6 +42,18 @@ describe('CourseCompetencyApiService', () => { await methodCall; }); + it('should update course competency relation', async () => { + const relationId = 1; + const relationType = CompetencyRelationType.EXTENDS; + const methodCall = courseCompetencyApiService.updateCourseCompetencyRelation(courseId, relationId, { newRelationType: relationType }); + const response = httpClient.expectOne({ + method: 'PATCH', + url: `${baseUrl}/courses/${courseId}/course-competencies/relations/${relationId}`, + }); + response.flush({}); + await methodCall; + }); + it('should create course competency relation', async () => { const relation = { tailCompetencyId: 1,