From 9daaeeb8dc0e52084c819b0a745b4aee6010442f Mon Sep 17 00:00:00 2001 From: Julian Christl Date: Wed, 20 Sep 2023 23:50:10 +0200 Subject: [PATCH 1/6] Development: Fix an issue with duplicated notification recipients in Bamboo (#7231) --- .../config/migration/MigrationRegistry.java | 2 + .../entries/BambooMigrationService.java | 47 ++- .../MigrationEntry20230808_203400.java | 8 +- .../MigrationEntry20230920_181600.java | 356 ++++++++++++++++++ 4 files changed, 393 insertions(+), 20 deletions(-) create mode 100644 src/main/java/de/tum/in/www1/artemis/config/migration/entries/MigrationEntry20230920_181600.java diff --git a/src/main/java/de/tum/in/www1/artemis/config/migration/MigrationRegistry.java b/src/main/java/de/tum/in/www1/artemis/config/migration/MigrationRegistry.java index 26f61a421d72..c885702ad9eb 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/migration/MigrationRegistry.java +++ b/src/main/java/de/tum/in/www1/artemis/config/migration/MigrationRegistry.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Component; import de.tum.in.www1.artemis.config.migration.entries.MigrationEntry20230808_203400; +import de.tum.in.www1.artemis.config.migration.entries.MigrationEntry20230920_181600; /** * This component allows registering certain entries containing functionality that gets executed on application startup. The entries must extend {@link MigrationEntry}. @@ -29,6 +30,7 @@ public MigrationRegistry(MigrationService migrationService) { this.migrationService = migrationService; this.migrationEntryMap.put(1, MigrationEntry20230808_203400.class); + this.migrationEntryMap.put(2, MigrationEntry20230920_181600.class); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/config/migration/entries/BambooMigrationService.java b/src/main/java/de/tum/in/www1/artemis/config/migration/entries/BambooMigrationService.java index fe28b3c57c7e..bb44f546c6c3 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/migration/entries/BambooMigrationService.java +++ b/src/main/java/de/tum/in/www1/artemis/config/migration/entries/BambooMigrationService.java @@ -22,6 +22,7 @@ import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; @@ -116,13 +117,29 @@ private static Optional getRepositoryNameById(String html, Long id) { @Override public void overrideBuildPlanNotification(String projectKey, String buildPlanKey, VcsRepositoryUrl vcsRepositoryUrl) { - List notificationIds = getAllArtemisBuildPlanServerNotificationIds(buildPlanKey); - - for (var notificationId : notificationIds) { - deleteBuildPlanServerNotificationId(buildPlanKey, notificationId); + Map notificationIds = getAllArtemisBuildPlanServerNotificationIds(buildPlanKey); + log.info("Found {} notifications for build plan {}", notificationIds.size(), buildPlanKey); + + List idsWithValidUrl = notificationIds.entrySet().stream().filter(entry -> entry.getValue().equals(artemisServerUrl + NEW_RESULT_RESOURCE_API_PATH)) + .map(Map.Entry::getKey).toList(); + boolean hasValidUrl = !idsWithValidUrl.isEmpty(); + if (hasValidUrl) { + log.info("Build plan {} already has a notification with the correct URL", buildPlanKey); + notificationIds.remove(idsWithValidUrl.get(0)); } - createBuildPlanServerNotification(buildPlanKey, artemisServerUrl + NEW_RESULT_RESOURCE_API_PATH); + notificationIds.forEach((id, url) -> { + try { + deleteBuildPlanServerNotificationId(buildPlanKey, id); + } + catch (RestClientException e) { + log.error("Could not delete notification with id " + id + " for build plan " + buildPlanKey, e); + } + }); + + if (!hasValidUrl) { + createBuildPlanServerNotification(buildPlanKey, artemisServerUrl + NEW_RESULT_RESOURCE_API_PATH); + } } @Override @@ -297,7 +314,7 @@ public boolean buildPlanExists(String projectKey, String buildPlanKey) { * @param buildPlanKey The key of the build plan, which is usually the name combined with the project, e.g. 'EIST16W1-GA56HUR'. * @return a list of all notification ids */ - private List getAllArtemisBuildPlanServerNotificationIds(String buildPlanKey) { + private Map getAllArtemisBuildPlanServerNotificationIds(String buildPlanKey) { MultiValueMap parameters = new LinkedMultiValueMap<>(); parameters.add("buildKey", buildPlanKey); String requestUrl = bambooServerUrl + "/chain/admin/config/defaultChainNotification.action"; @@ -306,16 +323,16 @@ private List getAllArtemisBuildPlanServerNotificationIds(String buildPlanK var response = restTemplate.exchange(builder.build().toUri(), HttpMethod.GET, null, String.class); var html = response.getBody(); if (html == null) { - return List.of(); + return Map.of(); } Element notificationTableBody = Jsoup.parse(html).selectFirst("table#notificationTable tbody"); if (notificationTableBody == null) { - return List.of(); + return Map.of(); } // First column is the event, second column the recipient, third the actions // If there is a URL, the URL is the recipient. In that case we take the notification id from the edit button Elements entries = notificationTableBody.select("tr"); - List notificationIds = new ArrayList<>(); + Map notificationIdToRecipient = new HashMap<>(); for (Element entry : entries) { Elements columns = entry.select("td"); if (columns.size() != 3) { @@ -324,16 +341,14 @@ private List getAllArtemisBuildPlanServerNotificationIds(String buildPlanK String recipient = columns.get(1).text(); String actions = columns.get(2).toString(); Pattern editNotificationIdPattern = Pattern.compile(".*?id=\"editNotification:(\\d+)\".*?"); - if (recipient.trim().startsWith(artemisServerUrl)) { - Matcher matcher = editNotificationIdPattern.matcher(actions); - if (matcher.find()) { - String notificationIdString = matcher.group(1); - notificationIds.add(Long.parseLong(notificationIdString)); - } + Matcher matcher = editNotificationIdPattern.matcher(actions); + if (matcher.find()) { + String notificationIdString = matcher.group(1); + notificationIdToRecipient.put(Long.parseLong(notificationIdString), recipient); } } - return notificationIds; + return notificationIdToRecipient; } /** diff --git a/src/main/java/de/tum/in/www1/artemis/config/migration/entries/MigrationEntry20230808_203400.java b/src/main/java/de/tum/in/www1/artemis/config/migration/entries/MigrationEntry20230808_203400.java index b71a2e8058c4..b4abc7498f84 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/migration/entries/MigrationEntry20230808_203400.java +++ b/src/main/java/de/tum/in/www1/artemis/config/migration/entries/MigrationEntry20230808_203400.java @@ -223,10 +223,10 @@ private void migrateSolutions(List par migrateSolutionBuildPlan(participation, auxiliaryRepositories); migrateTestRepository(participation); - log.info("Migrated template build plan for exercise {} in {}ms", participation.getProgrammingExercise().getId(), System.currentTimeMillis() - startMs); + log.info("Migrated solution build plan for exercise {} in {}ms", participation.getProgrammingExercise().getId(), System.currentTimeMillis() - startMs); } catch (Exception e) { - log.warn("Failed to migrate template build plan for exercise {} with buildPlanId {}", participation.getProgrammingExercise().getId(), + log.warn("Failed to migrate solution build plan for exercise {} with buildPlanId {}", participation.getProgrammingExercise().getId(), participation.getBuildPlanId(), e); errorList.add(participation); } @@ -319,8 +319,8 @@ private void migrateStudents(List parti log.info("Migrated student build plan for exercise {} in {}ms", participation.getProgrammingExercise().getId(), System.currentTimeMillis() - startMs); } catch (Exception e) { - log.warn("Failed to migrate template build plan for exercise {} with buildPlanId {}", participation.getProgrammingExercise().getId(), - participation.getBuildPlanId(), e); + log.warn("Failed to migrate student build plan for exercise {} with buildPlanId {}", participation.getProgrammingExercise().getId(), participation.getBuildPlanId(), + e); errorList.add(participation); } } diff --git a/src/main/java/de/tum/in/www1/artemis/config/migration/entries/MigrationEntry20230920_181600.java b/src/main/java/de/tum/in/www1/artemis/config/migration/entries/MigrationEntry20230920_181600.java new file mode 100644 index 000000000000..e9fbdf8dc135 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/config/migration/entries/MigrationEntry20230920_181600.java @@ -0,0 +1,356 @@ +package de.tum.in.www1.artemis.config.migration.entries; + +import java.net.URISyntaxException; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.env.Environment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import com.google.common.collect.Lists; + +import de.tum.in.www1.artemis.config.migration.MigrationEntry; +import de.tum.in.www1.artemis.domain.ProgrammingExercise; +import de.tum.in.www1.artemis.domain.VcsRepositoryUrl; +import de.tum.in.www1.artemis.domain.participation.*; +import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.service.UrlService; +import de.tum.in.www1.artemis.service.connectors.vcs.VersionControlService; + +@Component +public class MigrationEntry20230920_181600 extends MigrationEntry { + + private static final int BATCH_SIZE = 100; + + private static final int MAX_THREAD_COUNT = 10; + + private static final String ERROR_MESSAGE = "Failed to migrate programming exercises within nine hours. Aborting migration."; + + private static final int TIMEOUT_IN_HOURS = 9; + + private final Logger log = LoggerFactory.getLogger(MigrationEntry20230920_181600.class); + + private final ProgrammingExerciseRepository programmingExerciseRepository; + + private final SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository; + + private final TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository; + + private final ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository; + + private final Optional ciMigrationService; + + private final Environment environment; + + private final UrlService urlService = new UrlService(); + + private final CopyOnWriteArrayList errorList = new CopyOnWriteArrayList<>(); + + private static final List MIGRATABLE_PROFILES = List.of("bamboo"); + + public MigrationEntry20230920_181600(ProgrammingExerciseRepository programmingExerciseRepository, + SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, + TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository, + ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, + Optional ciMigrationService, Optional versionControlService, Environment environment) { + this.programmingExerciseRepository = programmingExerciseRepository; + this.solutionProgrammingExerciseParticipationRepository = solutionProgrammingExerciseParticipationRepository; + this.templateProgrammingExerciseParticipationRepository = templateProgrammingExerciseParticipationRepository; + this.programmingExerciseStudentParticipationRepository = programmingExerciseStudentParticipationRepository; + this.ciMigrationService = ciMigrationService; + this.environment = environment; + } + + @Override + public void execute() { + List activeProfiles = List.of(environment.getActiveProfiles()); + if (activeProfiles.stream().noneMatch(MIGRATABLE_PROFILES::contains)) { + log.info("Migration will be skipped and marked run because the system does not support a tech-stack that requires this migration: {}", activeProfiles); + return; + } + + var programmingExerciseCount = programmingExerciseRepository.count(); + var studentCount = ciMigrationService.orElseThrow().getPageableStudentParticipations(programmingExerciseStudentParticipationRepository, Pageable.unpaged()) + .getTotalElements(); + + log.info("Will migrate {} programming exercises and {} student participations now.", programmingExerciseCount, studentCount); + + // Number of full batches. The last batch might be smaller + long totalFullBatchCount = programmingExerciseCount / BATCH_SIZE; + int threadCount = (int) Math.max(1, Math.min(totalFullBatchCount, MAX_THREAD_COUNT)); + + // Use fixed thread pool to prevent loading too many exercises into memory at once + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + + /* + * migrate the solution participations first, then the template participations, then the student participations + */ + var solutionCount = solutionProgrammingExerciseParticipationRepository.count(); + log.info("Found {} solution participations to migrate.", solutionCount); + for (int currentPageStart = 0; currentPageStart < solutionCount; currentPageStart += BATCH_SIZE) { + Pageable pageable = PageRequest.of(currentPageStart / BATCH_SIZE, BATCH_SIZE); + var solutionParticipationPage = solutionProgrammingExerciseParticipationRepository.findAll(pageable); + log.info("Will migrate {} solution participations in batch.", solutionParticipationPage.getNumberOfElements()); + var solutionParticipationsPartitions = Lists.partition(solutionParticipationPage.toList(), threadCount); + for (var solutionParticipations : solutionParticipationsPartitions) { + executorService.submit(() -> migrateSolutions(solutionParticipations)); + } + } + + log.info("Submitted all solution participations to thread pool for migration."); + /* + * migrate the template participations + */ + var templateCount = templateProgrammingExerciseParticipationRepository.count(); + log.info("Found {} template participations to migrate", templateCount); + for (int currentPageStart = 0; currentPageStart < templateCount; currentPageStart += BATCH_SIZE) { + Pageable pageable = PageRequest.of(currentPageStart / BATCH_SIZE, BATCH_SIZE); + var templateParticipationPage = templateProgrammingExerciseParticipationRepository.findAll(pageable); + log.info("Will migrate {} template programming exercises in batch.", templateParticipationPage.getNumberOfElements()); + var templateParticipationsPartitions = Lists.partition(templateParticipationPage.toList(), threadCount); + for (var templateParticipations : templateParticipationsPartitions) { + executorService.submit(() -> migrateTemplates(templateParticipations)); + } + } + + log.info("Submitted all template participations to thread pool for migration."); + /* + * migrate the student participations + */ + log.info("Found {} student programming exercise participations with build plans to migrate.", studentCount); + for (int currentPageStart = 0; currentPageStart < studentCount; currentPageStart += BATCH_SIZE) { + Pageable pageable = PageRequest.of(currentPageStart / BATCH_SIZE, BATCH_SIZE); + Page studentParticipationPage = ciMigrationService.orElseThrow() + .getPageableStudentParticipations(programmingExerciseStudentParticipationRepository, pageable); + log.info("Will migrate {} student programming exercise participations in batch.", studentParticipationPage.getNumberOfElements()); + var studentPartitionsPartitions = Lists.partition(studentParticipationPage.toList(), threadCount); + for (var studentParticipations : studentPartitionsPartitions) { + executorService.submit(() -> migrateStudents(studentParticipations)); + } + } + + // Wait for all threads to finish + executorService.shutdown(); + + try { + boolean finished = executorService.awaitTermination(TIMEOUT_IN_HOURS, TimeUnit.HOURS); + if (!finished) { + log.error(ERROR_MESSAGE); + if (executorService.awaitTermination(1, TimeUnit.MINUTES)) { + log.error("Failed to cancel all migration threads. Some threads are still running."); + } + throw new RuntimeException(ERROR_MESSAGE); + } + } + catch (InterruptedException e) { + log.error(ERROR_MESSAGE); + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + + log.info("Finished migrating programming exercises and student participations"); + evaluateErrorList(); + } + + /** + * Executes the migration for a batch of participations. + * + * @param participations The participations to migrate + */ + private void migrateSolutions(List participations) { + for (var participation : participations) { + try { + // 1st step: check if the build plan exists, this cleans up the database a bit + boolean buildPlanExists = ciMigrationService.orElseThrow().buildPlanExists(participation.getProgrammingExercise().getProjectKey(), participation.getBuildPlanId()); + if (!buildPlanExists) { + log.warn("Build plan {} does not exist for exercise {}", participation.getBuildPlanId(), participation.getProgrammingExercise().getId()); + log.info("Skipping migration for exercise {} and setting build_plan_id to null", participation.getProgrammingExercise().getId()); + participation.setBuildPlanId(null); + solutionProgrammingExerciseParticipationRepository.save(participation); + continue; + } + + log.info("Migrating solution build plan with name {} for exercise {}", + participation.getProgrammingExercise().getProjectKey() + "-" + participation.getBuildPlanId(), participation.getProgrammingExercise().getId()); + + migrateSolutionBuildPlan(participation); + } + catch (Exception e) { + log.warn("Failed to migrate solution build plan for exercise {} with buildPlanId {}", participation.getProgrammingExercise().getId(), + participation.getBuildPlanId(), e); + errorList.add(participation); + } + } + } + + /** + * Migrates the build plans of the given participations. + * + * @param participations The participations to migrate + */ + private void migrateTemplates(List participations) { + for (var participation : participations) { + try { + // 1st step: check if the build plan exists, this cleans up the database a bit + boolean buildPlanExists = ciMigrationService.orElseThrow().buildPlanExists(participation.getProgrammingExercise().getProjectKey(), + participation.getProgrammingExercise().getTemplateBuildPlanId()); + if (!buildPlanExists) { + log.warn("Build plan {} does not exist for exercise {}", participation.getProgrammingExercise().getTemplateBuildPlanId(), + participation.getProgrammingExercise().getId()); + log.info("Skipping migration for template of exercise {} and setting build_plan_id to null", participation.getProgrammingExercise().getId()); + participation.setBuildPlanId(null); + templateProgrammingExerciseParticipationRepository.save(participation); + // CANCEL THE SUBSEQUENT OPERATIONS FOR THIS PROGRAMMING EXERCISE + continue; + } + + log.info("Migrating template build plan with name {} for exercise {}", + participation.getProgrammingExercise().getProjectKey() + "-" + participation.getBuildPlanId(), participation.getProgrammingExercise().getId()); + + migrateTemplateBuildPlan(participation); + } + catch (Exception e) { + log.warn("Failed to migrate template build plan for exercise {} with buildPlanId {}", participation.getProgrammingExercise().getId(), + participation.getBuildPlanId(), e); + errorList.add(participation); + } + } + } + + /** + * Migrates the build plans of the given participations. + * + * @param participations The participations to migrate + */ + private void migrateStudents(List participations) { + for (var participation : participations) { + try { + // 1st step: check if the build plan exists, this cleans up the database a bit + boolean buildPlanExists = ciMigrationService.orElseThrow().buildPlanExists(participation.getProgrammingExercise().getProjectKey(), participation.getBuildPlanId()); + if (!buildPlanExists) { + log.warn("Build plan {} does not exist for exercise {}", participation.getBuildPlanId(), participation.getProgrammingExercise().getId()); + log.info("Skipping migration for exercise {} and setting build_plan_id to null", participation.getProgrammingExercise().getId()); + participation.setBuildPlanId(null); + programmingExerciseStudentParticipationRepository.save(participation); + continue; + } + + log.info("Migrating student participation with buildPlanId {} for exercise {}", participation.getBuildPlanId(), participation.getProgrammingExercise().getId()); + + migrateStudentBuildPlan(participation); + } + catch (Exception e) { + log.warn("Failed to migrate student build plan for exercise {} with buildPlanId {}", participation.getProgrammingExercise().getId(), participation.getBuildPlanId(), + e); + errorList.add(participation); + } + } + } + + /** + * Migrates a single solution build plan. + * + * @param participation The participation to migrate + */ + private void migrateStudentBuildPlan(ProgrammingExerciseStudentParticipation participation) { + VcsRepositoryUrl repositoryUrl; + ProgrammingExercise exercise = participation.getProgrammingExercise(); + try { + repositoryUrl = new VcsRepositoryUrl(urlService.getPlainUrlFromRepositoryUrl(participation.getVcsRepositoryUrl())); + } + catch (URISyntaxException e) { + log.warn("Failed to convert git url {} for studentParticipationId {} exerciseId {} with buildPlanId {}, will abort migration for this Participation", + participation.getVcsRepositoryUrl(), participation.getId(), exercise.getId(), participation.getBuildPlanId(), e); + errorList.add(participation); + return; + } + try { + ciMigrationService.orElseThrow().overrideBuildPlanNotification(exercise.getProjectKey(), participation.getBuildPlanId(), repositoryUrl); + } + catch (Exception e) { + log.warn("Failed to migrate build plan notifications for studentParticipationId {} with buildPlanId {} of exerciseId {} ", participation.getId(), + participation.getBuildPlanId(), exercise.getId(), e); + errorList.add(participation); + } + } + + /** + * Migrates a single solution build plan. + * + * @param participation The participation to migrate + */ + private void migrateSolutionBuildPlan(AbstractBaseProgrammingExerciseParticipation participation) { + ProgrammingExercise exercise = participation.getProgrammingExercise(); + try { + ciMigrationService.orElseThrow().overrideBuildPlanNotification(exercise.getProjectKey(), exercise.getSolutionBuildPlanId(), exercise.getVcsSolutionRepositoryUrl()); + } + catch (Exception e) { + log.warn("Failed to migrate solution build plan for exercise id {} with buildPlanId {}", exercise.getId(), exercise.getSolutionBuildPlanId(), e); + errorList.add(participation); + } + } + + /** + * Migrates a single template build plan. + * + * @param participation The participation to migrate + */ + private void migrateTemplateBuildPlan(AbstractBaseProgrammingExerciseParticipation participation) { + ProgrammingExercise exercise = participation.getProgrammingExercise(); + try { + ciMigrationService.orElseThrow().overrideBuildPlanNotification(exercise.getProjectKey(), exercise.getTemplateBuildPlanId(), exercise.getVcsTemplateRepositoryUrl()); + } + catch (Exception e) { + log.warn("Failed to migrate template build plan for exercise {} with buildPlanId {}", exercise.getId(), exercise.getTemplateBuildPlanId(), e); + errorList.add(participation); + } + } + + /** + * Evaluates the error map and prints the errors to the log. + */ + private void evaluateErrorList() { + if (errorList.isEmpty()) { + log.info("Successfully migrated all programming exercises"); + return; + } + + List failedTemplateExercises = errorList.stream().filter(participation -> participation instanceof TemplateProgrammingExerciseParticipation) + .map(participation -> participation.getProgrammingExercise().getId()).toList(); + List failedSolutionExercises = errorList.stream().filter(participation -> participation instanceof SolutionProgrammingExerciseParticipation) + .map(participation -> participation.getProgrammingExercise().getId()).toList(); + List failedStudentParticipations = errorList.stream().filter(participation -> participation instanceof ProgrammingExerciseStudentParticipation) + .map(ParticipationInterface::getId).toList(); + + log.error("{} failures during migration", errorList.size()); + // print each participation in a single line in the long to simplify reviewing the issues + log.error("Errors occurred for the following participations: \n{}", errorList.stream().map(Object::toString).collect(Collectors.joining("\n"))); + log.error("Failed to migrate template build plan for exercises: {}", failedTemplateExercises); + log.error("Failed to migrate solution build plan for exercises: {}", failedSolutionExercises); + log.error("Failed to migrate students participations: {}", failedStudentParticipations); + log.warn("Please check the logs for more information. If the issues are related to the external VCS/CI system, fix the issues and rerun the migration. or " + + "fix the build plans yourself and mark the migration as run. The migration can be rerun by deleting the migration entry in the database table containing " + + "the migration with author: " + author() + " and date_string: " + date() + " and then restarting Artemis."); + } + + @Override + public String author() { + return "julian-christl"; + } + + @Override + public String date() { + return "20230920_181600"; + } +} From a8464c912544cdd0d5a1e11d0c2fb676c6bc0e51 Mon Sep 17 00:00:00 2001 From: Maximilian Anzinger <44003963+MaximilianAnzinger@users.noreply.github.com> Date: Thu, 21 Sep 2023 10:17:35 +0200 Subject: [PATCH 2/6] Adaptive learning: Fix display of inner html for competency descriptions (#7236) --- .../competencies/competency-card/competency-card.component.html | 2 +- .../course-competencies-details.component.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/webapp/app/course/competencies/competency-card/competency-card.component.html b/src/main/webapp/app/course/competencies/competency-card/competency-card.component.html index 180c1b1b7ec9..1acd70fee152 100644 --- a/src/main/webapp/app/course/competencies/competency-card/competency-card.component.html +++ b/src/main/webapp/app/course/competencies/competency-card/competency-card.component.html @@ -24,7 +24,7 @@

Mastered Optional

-

{{ competency.description }}

+

{{ competency.course.title }} {{ competency.course.semester }} diff --git a/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.html b/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.html index c8a10a012932..b6e75469719f 100644 --- a/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.html +++ b/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.html @@ -23,7 +23,7 @@

Mastered Optional

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

+ * A competency's representation consists of + *

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

+ * The representation will contain: + *

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

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

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

+ + {{ exercise.title }} +

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

+ + {{ lectureUnit.name }} +

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

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

+
+
+ + + +
+
diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.ts new file mode 100644 index 000000000000..9b4a8d38c686 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-progress-nav.component.ts @@ -0,0 +1,18 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { faArrowsRotate, faArrowsToEye, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; + +@Component({ + selector: 'jhi-learning-path-progress-nav', + templateUrl: './learning-path-progress-nav.component.html', +}) +export class LearningPathProgressNavComponent { + @Input() learningPath: LearningPathPageableSearchDTO; + @Output() onRefresh: EventEmitter = new EventEmitter(); + @Output() onCenterView: EventEmitter = new EventEmitter(); + @Output() onClose: EventEmitter = new EventEmitter(); + + faXmark = faXmark; + faArrowsToEye = faArrowsToEye; + faArrowsRotate = faArrowsRotate; +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path.service.ts b/src/main/webapp/app/course/learning-paths/learning-path.service.ts index ece1bc6b204a..b201a57f84d3 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path.service.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path.service.ts @@ -2,6 +2,8 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { HttpClient, HttpResponse } from '@angular/common/http'; import { LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { NgxLearningPathDTO } from 'app/entities/competency/learning-path.model'; +import { map } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class LearningPathService { @@ -20,4 +22,18 @@ export class LearningPathService { getHealthStatusForCourse(courseId: number) { return this.httpClient.get(`${this.resourceURL}/courses/${courseId}/learning-path-health`, { observe: 'response' }); } + + getLearningPathNgxGraph(learningPathId: number): Observable> { + return this.httpClient.get(`${this.resourceURL}/learning-path/${learningPathId}/graph`, { observe: 'response' }).pipe( + map((ngxLearningPathResponse) => { + if (!ngxLearningPathResponse.body!.nodes) { + ngxLearningPathResponse.body!.nodes = []; + } + if (!ngxLearningPathResponse.body!.edges) { + ngxLearningPathResponse.body!.edges = []; + } + return ngxLearningPathResponse; + }), + ); + } } diff --git a/src/main/webapp/app/course/learning-paths/learning-paths.module.ts b/src/main/webapp/app/course/learning-paths/learning-paths.module.ts index 4d1a254a674f..8c37905813e9 100644 --- a/src/main/webapp/app/course/learning-paths/learning-paths.module.ts +++ b/src/main/webapp/app/course/learning-paths/learning-paths.module.ts @@ -3,10 +3,29 @@ import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { LearningPathManagementComponent } from 'app/course/learning-paths/learning-path-management/learning-path-management.component'; +import { NgxGraphModule } from '@swimlane/ngx-graph'; +import { ArtemisLectureUnitsModule } from 'app/overview/course-lectures/lecture-units.module'; +import { ArtemisCompetenciesModule } from 'app/course/competencies/competency.module'; +import { LearningPathProgressModalComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-modal.component'; +import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; +import { LearningPathProgressNavComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-nav.component'; +import { LearningPathGraphNodeComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph-node.component'; +import { CompetencyNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component'; +import { LectureUnitNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component'; +import { ExerciseNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component'; @NgModule({ - imports: [ArtemisSharedModule, FormsModule, ReactiveFormsModule, ArtemisSharedComponentModule], - declarations: [LearningPathManagementComponent], + imports: [ArtemisSharedModule, FormsModule, ReactiveFormsModule, ArtemisSharedComponentModule, NgxGraphModule, ArtemisLectureUnitsModule, ArtemisCompetenciesModule], + declarations: [ + LearningPathManagementComponent, + LearningPathProgressModalComponent, + LearningPathProgressNavComponent, + LearningPathGraphComponent, + LearningPathGraphNodeComponent, + CompetencyNodeDetailsComponent, + LectureUnitNodeDetailsComponent, + ExerciseNodeDetailsComponent, + ], exports: [], }) export class ArtemisLearningPathsModule {} diff --git a/src/main/webapp/app/entities/competency/learning-path.model.ts b/src/main/webapp/app/entities/competency/learning-path.model.ts index aee01ef000cd..8834651fbd21 100644 --- a/src/main/webapp/app/entities/competency/learning-path.model.ts +++ b/src/main/webapp/app/entities/competency/learning-path.model.ts @@ -2,6 +2,7 @@ import { BaseEntity } from 'app/shared/model/base-entity'; import { Course } from 'app/entities/course.model'; import { User, UserNameAndLoginDTO } from 'app/core/user/user.model'; import { Competency } from 'app/entities/competency.model'; +import { Edge, Node } from '@swimlane/ngx-graph'; export class LearningPath implements BaseEntity { public id?: number; @@ -18,3 +19,32 @@ export class LearningPathPageableSearchDTO { public user?: UserNameAndLoginDTO; public progress?: number; } + +export class NgxLearningPathDTO { + public nodes: NgxLearningPathNode[]; + public edges: NgxLearningPathEdge[]; +} + +export class NgxLearningPathNode implements Node { + public id: string; + public type?: NodeType; + public linkedResource?: number; + public linkedResourceParent?: number; + public completed?: boolean; + public label?: string; +} + +export class NgxLearningPathEdge implements Edge { + public id?: string; + public source: string; + public target: string; +} + +export enum NodeType { + COMPETENCY_START = 'COMPETENCY_START', + COMPETENCY_END = 'COMPETENCY_END', + MATCH_START = 'MATCH_START', + MATCH_END = 'MATCH_END', + EXERCISE = 'EXERCISE', + LECTURE_UNIT = 'LECTURE_UNIT', +} diff --git a/src/main/webapp/app/entities/lecture-unit/lectureUnit.model.ts b/src/main/webapp/app/entities/lecture-unit/lectureUnit.model.ts index 647d462e0cb7..48863fff879d 100644 --- a/src/main/webapp/app/entities/lecture-unit/lectureUnit.model.ts +++ b/src/main/webapp/app/entities/lecture-unit/lectureUnit.model.ts @@ -2,6 +2,8 @@ import { BaseEntity } from 'app/shared/model/base-entity'; import dayjs from 'dayjs/esm'; import { Lecture } from 'app/entities/lecture.model'; import { Competency } from 'app/entities/competency.model'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { faDownload, faLink, faQuestion, faScroll, faVideo } from '@fortawesome/free-solid-svg-icons'; // IMPORTANT NOTICE: The following strings have to be consistent with // the ones defined in LectureUnit.java @@ -13,6 +15,22 @@ export enum LectureUnitType { ONLINE = 'online', } +export const lectureUnitIcons = { + [LectureUnitType.ATTACHMENT]: faDownload, + [LectureUnitType.EXERCISE]: faQuestion, + [LectureUnitType.TEXT]: faScroll, + [LectureUnitType.VIDEO]: faVideo, + [LectureUnitType.ONLINE]: faLink, +}; + +export const lectureUnitTooltips = { + [LectureUnitType.ATTACHMENT]: 'artemisApp.attachmentUnit.tooltip', + [LectureUnitType.EXERCISE]: '', + [LectureUnitType.TEXT]: 'artemisApp.textUnit.tooltip', + [LectureUnitType.VIDEO]: 'artemisApp.videoUnit.tooltip', + [LectureUnitType.ONLINE]: 'artemisApp.onlineUnit.tooltip', +}; + export abstract class LectureUnit implements BaseEntity { public id?: number; public name?: string; @@ -28,3 +46,23 @@ export abstract class LectureUnit implements BaseEntity { this.type = type; } } + +export function getIcon(lectureUnitType: LectureUnitType): IconProp { + if (!lectureUnitType) { + return faQuestion as IconProp; + } + return lectureUnitIcons[lectureUnitType] as IconProp; +} + +export function getIconTooltip(lectureUnitType: LectureUnitType) { + if (!lectureUnitType) { + return ''; + } + return lectureUnitTooltips[lectureUnitType]; +} + +export class LectureUnitForLearningPathNodeDetailsDTO { + public id?: number; + public name?: string; + public type?: LectureUnitType; +} diff --git a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts index 7405159f676e..c16717ef0e0f 100644 --- a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts +++ b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service.ts @@ -1,4 +1,4 @@ -import { LectureUnit, LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; +import { LectureUnit, LectureUnitForLearningPathNodeDetailsDTO, LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -138,4 +138,10 @@ export class LectureUnitService { return lectureUnit.releaseDate; } } + + getLectureUnitForLearningPathNodeDetails(lectureUnitId: number) { + return this.httpClient.get(`${this.resourceURL}/lecture-units/${lectureUnitId}/for-learning-path-node-details`, { + observe: 'response', + }); + } } diff --git a/src/main/webapp/app/shared/shared.module.ts b/src/main/webapp/app/shared/shared.module.ts index b63a45c1c99a..a7b2c03ec4a4 100644 --- a/src/main/webapp/app/shared/shared.module.ts +++ b/src/main/webapp/app/shared/shared.module.ts @@ -23,6 +23,7 @@ import { AssessmentWarningComponent } from 'app/assessment/assessment-warning/as import { JhiConnectionWarningComponent } from 'app/shared/connection-warning/connection-warning.component'; import { LoadingIndicatorContainerComponent } from 'app/shared/loading-indicator-container/loading-indicator-container.component'; import { CompetencySelectionComponent } from 'app/shared/competency-selection/competency-selection.component'; +import { StickyPopoverDirective } from 'app/shared/sticky-popover/sticky-popover.directive'; import { ConfirmEntityNameComponent } from 'app/shared/confirm-entity-name/confirm-entity-name.component'; @NgModule({ @@ -49,6 +50,7 @@ import { ConfirmEntityNameComponent } from 'app/shared/confirm-entity-name/confi ItemCountComponent, ConsistencyCheckComponent, AssessmentWarningComponent, + StickyPopoverDirective, ], exports: [ ArtemisSharedLibsModule, @@ -75,6 +77,7 @@ import { ConfirmEntityNameComponent } from 'app/shared/confirm-entity-name/confi ConsistencyCheckComponent, AssessmentWarningComponent, CompetencySelectionComponent, + StickyPopoverDirective, ], }) export class ArtemisSharedModule {} diff --git a/src/main/webapp/app/shared/sticky-popover/sticky-popover.directive.ts b/src/main/webapp/app/shared/sticky-popover/sticky-popover.directive.ts new file mode 100644 index 000000000000..4ec7db908407 --- /dev/null +++ b/src/main/webapp/app/shared/sticky-popover/sticky-popover.directive.ts @@ -0,0 +1,119 @@ +import { + ApplicationRef, + ChangeDetectorRef, + Directive, + ElementRef, + Inject, + Injector, + Input, + NgZone, + OnDestroy, + OnInit, + Renderer2, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { NgbPopover, NgbPopoverConfig } from '@ng-bootstrap/ng-bootstrap'; + +@Directive({ + selector: '[jhiStickyPopover]', +}) +export class StickyPopoverDirective extends NgbPopover implements OnInit, OnDestroy { + @Input() jhiStickyPopover: TemplateRef; + + popoverTitle: string; + + triggers: string; + container: string; + canClosePopover: boolean; + + private closeTimeout: any; + private clickInPopover: boolean = false; + + toggle(): void { + super.toggle(); + } + + isOpen(): boolean { + return super.isOpen(); + } + + constructor( + private _elRef: ElementRef, + private _render: Renderer2, + injector: Injector, + private viewContainerRef: ViewContainerRef, + config: NgbPopoverConfig, + ngZone: NgZone, + private changeRef: ChangeDetectorRef, + private applicationRef: ApplicationRef, + @Inject(DOCUMENT) _document: any, + ) { + super(_elRef, _render, injector, viewContainerRef, config, ngZone, _document, changeRef, applicationRef); + this.triggers = 'manual'; + this.popoverTitle = ''; + this.container = 'body'; + } + + ngOnInit(): void { + super.ngOnInit(); + this.ngbPopover = this.jhiStickyPopover; + + this._render.listen(this._elRef.nativeElement, 'pointerenter', () => { + this.canClosePopover = true; + clearTimeout(this.closeTimeout); + if (!this.isOpen()) { + this.open(); + } + }); + + this._render.listen(this._elRef.nativeElement, 'pointerleave', () => { + this.closeTimeout = setTimeout(() => { + if (this.canClosePopover) { + this.close(); + } + }, 100); + }); + + this._render.listen(this._elRef.nativeElement, 'click', () => { + this.close(); + }); + } + + ngOnDestroy(): void { + super.ngOnDestroy(); + } + + open() { + super.open(); + setTimeout(() => { + const popover = window.document.querySelector('.popover'); + this._render.listen(popover, 'mouseover', () => { + this.canClosePopover = false; + }); + + this._render.listen(popover, 'pointerleave', () => { + this.canClosePopover = true; + clearTimeout(this.closeTimeout); + this.closeTimeout = setTimeout(() => { + if (this.canClosePopover) { + this.close(); + } + }, 250); + }); + + this._render.listen(popover, 'click', () => { + this.clickInPopover = true; + }); + }, 0); + } + + close() { + if (this.clickInPopover) { + this.clickInPopover = false; + } else { + super.close(); + } + } +} diff --git a/src/main/webapp/i18n/de/competency.json b/src/main/webapp/i18n/de/competency.json index 62d59e2cd035..6543d4a798a8 100644 --- a/src/main/webapp/i18n/de/competency.json +++ b/src/main/webapp/i18n/de/competency.json @@ -169,6 +169,11 @@ "name": "Name", "login": "Login", "progress": "Fortschritt" + }, + "progressNav": { + "header": "Lernpfad", + "refresh": "Aktualisieren", + "center": "Zentrieren" } } } diff --git a/src/main/webapp/i18n/en/competency.json b/src/main/webapp/i18n/en/competency.json index fbc7c96a12aa..ba6dc5f20b2e 100644 --- a/src/main/webapp/i18n/en/competency.json +++ b/src/main/webapp/i18n/en/competency.json @@ -168,6 +168,11 @@ "name": "Name", "login": "Login", "progress": "Progress" + }, + "progressNav": { + "header": "Learning Path", + "refresh": "Refresh", + "center": "Center view" } } } diff --git a/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java index e478eb07629c..c2bab0712b0e 100644 --- a/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/lecture/LearningPathIntegrationTest.java @@ -402,4 +402,54 @@ void testUpdateLearningPathProgress() throws Exception { void testGetHealthStatusForCourse() throws Exception { request.get("/api/courses/" + course.getId() + "/learning-path-health", HttpStatus.OK, LearningPathHealthDTO.class); } + + @Test + @WithMockUser(username = STUDENT_OF_COURSE, roles = "USER") + void testGetLearningPathNgxGraphForLearningPathsDisabled() throws Exception { + course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); + final var student = userRepository.findOneByLogin(STUDENT_OF_COURSE).orElseThrow(); + final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), student.getId()); + course.setLearningPathsEnabled(false); + courseRepository.save(course); + request.get("/api/learning-path/" + learningPath.getId() + "/graph", HttpStatus.BAD_REQUEST, NgxLearningPathDTO.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student2", roles = "USER") + void testGetLearningPathNgxGraphForOtherStudent() throws Exception { + course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); + final var student = userRepository.findOneByLogin(STUDENT_OF_COURSE).orElseThrow(); + final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), student.getId()); + request.get("/api/learning-path/" + learningPath.getId() + "/graph", HttpStatus.FORBIDDEN, NgxLearningPathDTO.class); + } + + /** + * This only tests if the end point successfully retrieves the graph representation. The correctness of the response is tested in LearningPathServiceTest. + * + * @throws Exception the request failed + * @see de.tum.in.www1.artemis.service.LearningPathServiceTest + */ + @Test + @WithMockUser(username = STUDENT_OF_COURSE, roles = "USER") + void testGetLearningPathNgxGraphAsStudent() throws Exception { + course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); + final var student = userRepository.findOneByLogin(STUDENT_OF_COURSE).orElseThrow(); + final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), student.getId()); + request.get("/api/learning-path/" + learningPath.getId() + "/graph", HttpStatus.OK, NgxLearningPathDTO.class); + } + + /** + * This only tests if the end point successfully retrieves the graph representation. The correctness of the response is tested in LearningPathServiceTest. + * + * @throws Exception the request failed + * @see de.tum.in.www1.artemis.service.LearningPathServiceTest + */ + @Test + @WithMockUser(username = INSTRUCTOR_OF_COURSE, roles = "INSTRUCTOR") + void testGetLearningPathNgxGraphAsInstructor() throws Exception { + course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); + final var student = userRepository.findOneByLogin(STUDENT_OF_COURSE).orElseThrow(); + final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), student.getId()); + request.get("/api/learning-path/" + learningPath.getId() + "/graph", HttpStatus.OK, NgxLearningPathDTO.class); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java index dca6b87250a9..dbd732522b5e 100644 --- a/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java @@ -21,6 +21,7 @@ import de.tum.in.www1.artemis.domain.lecture.*; import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.user.UserUtilService; +import de.tum.in.www1.artemis.web.rest.dto.lectureunit.LectureUnitForLearningPathNodeDetailsDTO; class LectureUnitIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { @@ -275,4 +276,16 @@ void setLectureUnitCompletion_shouldReturnForbidden() throws Exception { HttpStatus.FORBIDDEN, null); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetLectureUnitForLearningPathNodeDetailsAsStudentOfCourse() throws Exception { + final var result = request.get("/api/lecture-units/" + textUnit.getId() + "/for-learning-path-node-details", HttpStatus.OK, LectureUnitForLearningPathNodeDetailsDTO.class); + assertThat(result).isEqualTo(LectureUnitForLearningPathNodeDetailsDTO.of(textUnit)); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student42", roles = "USER") + void testGetLectureUnitForLearningPathNodeDetailsAsStudentNotInCourse() throws Exception { + request.get("/api/lecture-units/" + textUnit.getId() + "/for-learning-path-node-details", HttpStatus.FORBIDDEN, LectureUnitForLearningPathNodeDetailsDTO.class); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java index f33149e252c9..0783a9e28b63 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java @@ -3,7 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.time.ZonedDateTime; -import java.util.HashSet; +import java.util.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -11,14 +11,24 @@ import org.springframework.beans.factory.annotation.Autowired; import de.tum.in.www1.artemis.AbstractSpringIntegrationBambooBitbucketJiraTest; +import de.tum.in.www1.artemis.competency.CompetencyUtilService; import de.tum.in.www1.artemis.competency.LearningPathUtilService; import de.tum.in.www1.artemis.course.CourseFactory; import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.domain.competency.Competency; +import de.tum.in.www1.artemis.domain.competency.CompetencyRelation; +import de.tum.in.www1.artemis.domain.competency.LearningPath; +import de.tum.in.www1.artemis.domain.lecture.LectureUnit; +import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; +import de.tum.in.www1.artemis.lecture.LectureUtilService; import de.tum.in.www1.artemis.repository.CourseRepository; +import de.tum.in.www1.artemis.repository.LearningPathRepository; import de.tum.in.www1.artemis.security.SecurityUtils; import de.tum.in.www1.artemis.user.UserUtilService; import de.tum.in.www1.artemis.web.rest.dto.competency.LearningPathHealthDTO; +import de.tum.in.www1.artemis.web.rest.dto.competency.NgxLearningPathDTO; class LearningPathServiceTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { @@ -39,6 +49,18 @@ class LearningPathServiceTest extends AbstractSpringIntegrationBambooBitbucketJi @Autowired private CourseRepository courseRepository; + @Autowired + private CompetencyUtilService competencyUtilService; + + @Autowired + private LectureUtilService lectureUtilService; + + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + + @Autowired + private LearningPathRepository learningPathRepository; + private Course course; @BeforeEach @@ -84,4 +106,192 @@ void testHealthStatusMissing() { assertThat(healthStatus.missingLearningPaths()).isEqualTo(1); } } + + @Nested + class GenerateNgxRepresentationBaseTest { + + @Test + void testEmptyLearningPath() { + NgxLearningPathDTO expected = new NgxLearningPathDTO(Set.of(), Set.of()); + generateAndAssert(expected); + } + + @Test + void testEmptyCompetency() { + final var competency = competencyUtilService.createCompetency(course); + final var startNodeId = LearningPathService.getCompetencyStartNodeId(competency.getId()); + final var endNodeId = LearningPathService.getCompetencyEndNodeId(competency.getId()); + Set expectedNodes = getExpectedNodesOfEmptyCompetency(competency); + Set expectedEdges = Set.of(new NgxLearningPathDTO.Edge(LearningPathService.getDirectEdgeId(competency.getId()), startNodeId, endNodeId)); + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + + @Test + void testCompetencyWithLectureUnitAndExercise() { + var competency = competencyUtilService.createCompetency(course); + var lecture = lectureUtilService.createLecture(course, ZonedDateTime.now()); + final var lectureUnit = lectureUtilService.createTextUnit(); + lectureUtilService.addLectureUnitsToLecture(lecture, List.of(lectureUnit)); + competencyUtilService.linkLectureUnitToCompetency(competency, lectureUnit); + final var exercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course, false); + competencyUtilService.linkExerciseToCompetency(competency, exercise); + final var startNodeId = LearningPathService.getCompetencyStartNodeId(competency.getId()); + final var endNodeId = LearningPathService.getCompetencyEndNodeId(competency.getId()); + Set expectedNodes = getExpectedNodesOfEmptyCompetency(competency); + expectedNodes.add(getNodeForLectureUnit(competency, lectureUnit)); + expectedNodes.add(getNodeForExercise(competency, exercise)); + Set expectedEdges = Set.of( + new NgxLearningPathDTO.Edge(LearningPathService.getLectureUnitInEdgeId(competency.getId(), lectureUnit.getId()), startNodeId, + LearningPathService.getLectureUnitNodeId(competency.getId(), lectureUnit.getId())), + new NgxLearningPathDTO.Edge(LearningPathService.getLectureUnitOutEdgeId(competency.getId(), lectureUnit.getId()), + LearningPathService.getLectureUnitNodeId(competency.getId(), lectureUnit.getId()), endNodeId), + new NgxLearningPathDTO.Edge(LearningPathService.getExerciseInEdgeId(competency.getId(), exercise.getId()), startNodeId, + LearningPathService.getExerciseNodeId(competency.getId(), exercise.getId())), + new NgxLearningPathDTO.Edge(LearningPathService.getExerciseOutEdgeId(competency.getId(), exercise.getId()), + LearningPathService.getExerciseNodeId(competency.getId(), exercise.getId()), endNodeId)); + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + + @Test + void testMultipleCompetencies() { + Competency[] competencies = { competencyUtilService.createCompetency(course), competencyUtilService.createCompetency(course), + competencyUtilService.createCompetency(course) }; + String[] startNodeIds = Arrays.stream(competencies).map(Competency::getId).map(LearningPathService::getCompetencyStartNodeId).toArray(String[]::new); + String[] endNodeIds = Arrays.stream(competencies).map(Competency::getId).map(LearningPathService::getCompetencyEndNodeId).toArray(String[]::new); + Set expectedNodes = new HashSet<>(); + Set expectedEdges = new HashSet<>(); + for (int i = 0; i < competencies.length; i++) { + expectedNodes.addAll(getExpectedNodesOfEmptyCompetency(competencies[i])); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getDirectEdgeId(competencies[i].getId()), startNodeIds[i], endNodeIds[i])); + } + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + } + + @Nested + class GenerateNgxRepresentationRelationTest { + + private Competency competency1; + + private Competency competency2; + + private Set expectedNodes; + + Set expectedEdges; + + @BeforeEach + void setup() { + competency1 = competencyUtilService.createCompetency(course); + competency2 = competencyUtilService.createCompetency(course); + expectedNodes = new HashSet<>(); + expectedEdges = new HashSet<>(); + addExpectedComponentsForEmptyCompetencies(competency1, competency2); + } + + void testSimpleRelation(CompetencyRelation.RelationType type) { + competencyUtilService.addRelation(competency1, type, competency2); + final var sourceNodeId = LearningPathService.getCompetencyEndNodeId(competency2.getId()); + final var targetNodeId = LearningPathService.getCompetencyStartNodeId(competency1.getId()); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getRelationEdgeId(sourceNodeId, targetNodeId), sourceNodeId, targetNodeId)); + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + + @Test + void testSingleRelates() { + testSimpleRelation(CompetencyRelation.RelationType.RELATES); + } + + @Test + void testSingleAssumes() { + testSimpleRelation(CompetencyRelation.RelationType.ASSUMES); + } + + @Test + void testSingleExtends() { + testSimpleRelation(CompetencyRelation.RelationType.EXTENDS); + } + + @Test + void testSingleMatches() { + competencyUtilService.addRelation(competency1, CompetencyRelation.RelationType.MATCHES, competency2); + expectedNodes.add(new NgxLearningPathDTO.Node(LearningPathService.getMatchingClusterStartNodeId(0), NgxLearningPathDTO.NodeType.MATCH_START, null, "")); + expectedNodes.add(new NgxLearningPathDTO.Node(LearningPathService.getMatchingClusterEndNodeId(0), NgxLearningPathDTO.NodeType.MATCH_END, null, "")); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getInEdgeId(competency1.getId()), LearningPathService.getMatchingClusterStartNodeId(0), + LearningPathService.getCompetencyStartNodeId(competency1.getId()))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getOutEdgeId(competency1.getId()), LearningPathService.getCompetencyEndNodeId(competency1.getId()), + LearningPathService.getMatchingClusterEndNodeId(0))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getInEdgeId(competency2.getId()), LearningPathService.getMatchingClusterStartNodeId(0), + LearningPathService.getCompetencyStartNodeId(competency2.getId()))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getOutEdgeId(competency2.getId()), LearningPathService.getCompetencyEndNodeId(competency2.getId()), + LearningPathService.getMatchingClusterEndNodeId(0))); + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + + @Test + void testMatchesTransitive() { + var competency3 = competencyUtilService.createCompetency(course); + addExpectedComponentsForEmptyCompetencies(competency3); + + competencyUtilService.addRelation(competency1, CompetencyRelation.RelationType.MATCHES, competency2); + competencyUtilService.addRelation(competency2, CompetencyRelation.RelationType.MATCHES, competency3); + expectedNodes.add(new NgxLearningPathDTO.Node(LearningPathService.getMatchingClusterStartNodeId(0), NgxLearningPathDTO.NodeType.MATCH_START, null, "")); + expectedNodes.add(new NgxLearningPathDTO.Node(LearningPathService.getMatchingClusterEndNodeId(0), NgxLearningPathDTO.NodeType.MATCH_END, null, "")); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getInEdgeId(competency1.getId()), LearningPathService.getMatchingClusterStartNodeId(0), + LearningPathService.getCompetencyStartNodeId(competency1.getId()))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getOutEdgeId(competency1.getId()), LearningPathService.getCompetencyEndNodeId(competency1.getId()), + LearningPathService.getMatchingClusterEndNodeId(0))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getInEdgeId(competency2.getId()), LearningPathService.getMatchingClusterStartNodeId(0), + LearningPathService.getCompetencyStartNodeId(competency2.getId()))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getOutEdgeId(competency2.getId()), LearningPathService.getCompetencyEndNodeId(competency2.getId()), + LearningPathService.getMatchingClusterEndNodeId(0))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getInEdgeId(competency3.getId()), LearningPathService.getMatchingClusterStartNodeId(0), + LearningPathService.getCompetencyStartNodeId(competency3.getId()))); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getOutEdgeId(competency3.getId()), LearningPathService.getCompetencyEndNodeId(competency3.getId()), + LearningPathService.getMatchingClusterEndNodeId(0))); + NgxLearningPathDTO expected = new NgxLearningPathDTO(expectedNodes, expectedEdges); + generateAndAssert(expected); + } + + private void addExpectedComponentsForEmptyCompetencies(Competency... competencies) { + for (var competency : competencies) { + expectedNodes.addAll(getExpectedNodesOfEmptyCompetency(competency)); + expectedEdges.add(new NgxLearningPathDTO.Edge(LearningPathService.getDirectEdgeId(competency.getId()), + LearningPathService.getCompetencyStartNodeId(competency.getId()), LearningPathService.getCompetencyEndNodeId(competency.getId()))); + } + } + } + + private void generateAndAssert(NgxLearningPathDTO expected) { + LearningPath learningPath = learningPathUtilService.createLearningPathInCourse(course); + learningPath = learningPathRepository.findWithEagerCompetenciesAndLearningObjectsAndCompletedUsersByIdElseThrow(learningPath.getId()); + NgxLearningPathDTO actual = learningPathService.generateNgxGraphRepresentation(learningPath); + assertThat(actual).isNotNull(); + assertNgxRepEquals(actual, expected); + } + + private void assertNgxRepEquals(NgxLearningPathDTO was, NgxLearningPathDTO expected) { + assertThat(was.nodes()).as("correct nodes").containsExactlyInAnyOrderElementsOf(expected.nodes()); + assertThat(was.edges()).as("correct edges").containsExactlyInAnyOrderElementsOf(expected.edges()); + } + + private static Set getExpectedNodesOfEmptyCompetency(Competency competency) { + return new HashSet<>(Set.of( + new NgxLearningPathDTO.Node(LearningPathService.getCompetencyStartNodeId(competency.getId()), NgxLearningPathDTO.NodeType.COMPETENCY_START, competency.getId(), ""), + new NgxLearningPathDTO.Node(LearningPathService.getCompetencyEndNodeId(competency.getId()), NgxLearningPathDTO.NodeType.COMPETENCY_END, competency.getId(), ""))); + } + + private static NgxLearningPathDTO.Node getNodeForLectureUnit(Competency competency, LectureUnit lectureUnit) { + return new NgxLearningPathDTO.Node(LearningPathService.getLectureUnitNodeId(competency.getId(), lectureUnit.getId()), NgxLearningPathDTO.NodeType.LECTURE_UNIT, + lectureUnit.getId(), lectureUnit.getLecture().getId(), false, lectureUnit.getName()); + } + + private static NgxLearningPathDTO.Node getNodeForExercise(Competency competency, Exercise exercise) { + return new NgxLearningPathDTO.Node(LearningPathService.getExerciseNodeId(competency.getId(), exercise.getId()), NgxLearningPathDTO.NodeType.EXERCISE, exercise.getId(), + exercise.getTitle()); + } } diff --git a/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph-node.component.spec.ts b/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph-node.component.spec.ts new file mode 100644 index 000000000000..8371428d66ed --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph-node.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../test.module'; +import { MockDirective } from 'ng-mocks'; +import { By } from '@angular/platform-browser'; +import { LearningPathGraphNodeComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph-node.component'; +import { NgxLearningPathNode, NodeType } from 'app/entities/competency/learning-path.model'; +import { StickyPopoverDirective } from 'app/shared/sticky-popover/sticky-popover.directive'; + +describe('LearningPathGraphNodeComponent', () => { + let fixture: ComponentFixture; + let comp: LearningPathGraphNodeComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + declarations: [LearningPathGraphNodeComponent, MockDirective(StickyPopoverDirective)], + providers: [], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(LearningPathGraphNodeComponent); + comp = fixture.componentInstance; + }); + }); + + it.each([NodeType.EXERCISE, NodeType.LECTURE_UNIT])('should display correct icon for completed learning object', (type: NodeType) => { + comp.node = { id: '1', type: type, completed: true } as NgxLearningPathNode; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('#completed')).nativeElement).toBeTruthy(); + }); + + it.each([NodeType.EXERCISE, NodeType.LECTURE_UNIT])('should display correct icon for not completed learning object', (type: NodeType) => { + comp.node = { id: '1', type: type, completed: false } as NgxLearningPathNode; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('#not-completed')).nativeElement).toBeTruthy(); + }); + + it.each([NodeType.COMPETENCY_START, NodeType.COMPETENCY_END, NodeType.COMPETENCY_START, NodeType.COMPETENCY_END])( + 'should display correct icon for generic node', + (type: NodeType) => { + comp.node = { id: '1', type: type } as NgxLearningPathNode; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('#generic')).nativeElement).toBeTruthy(); + }, + ); +}); diff --git a/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph.component.spec.ts b/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph.component.spec.ts new file mode 100644 index 000000000000..42f707efb83c --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/graph/learning-path-graph.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../test.module'; +import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; +import { LearningPathService } from 'app/course/learning-paths/learning-path.service'; + +describe('LearningPathGraphComponent', () => { + let fixture: ComponentFixture; + let comp: LearningPathGraphComponent; + let learningPathService: LearningPathService; + let getLearningPathNgxGraphStub: jest.SpyInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + declarations: [LearningPathGraphComponent], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(LearningPathGraphComponent); + comp = fixture.componentInstance; + learningPathService = TestBed.inject(LearningPathService); + getLearningPathNgxGraphStub = jest.spyOn(learningPathService, 'getLearningPathNgxGraph'); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should load learning path from service', () => { + comp.learningPathId = 1; + fixture.detectChanges(); + expect(getLearningPathNgxGraphStub).toHaveBeenCalledOnce(); + expect(getLearningPathNgxGraphStub).toHaveBeenCalledWith(1); + }); + + it('should update, center, and zoom to fit on resize', () => { + const updateStub = jest.spyOn(comp.update$, 'next'); + const centerStub = jest.spyOn(comp.center$, 'next'); + const zoomToFitStub = jest.spyOn(comp.zoomToFit$, 'next'); + fixture.detectChanges(); + comp.onResize(); + expect(updateStub).toHaveBeenCalledOnce(); + expect(updateStub).toHaveBeenCalledWith(true); + expect(centerStub).toHaveBeenCalledOnce(); + expect(centerStub).toHaveBeenCalledWith(true); + expect(zoomToFitStub).toHaveBeenCalledOnce(); + expect(zoomToFitStub).toHaveBeenCalledWith(true); + }); + + it('should zoom to fit and center on resize', () => { + const zoomToFitStub = jest.spyOn(comp.zoomToFit$, 'next'); + const centerStub = jest.spyOn(comp.center$, 'next'); + fixture.detectChanges(); + comp.onCenterView(); + expect(zoomToFitStub).toHaveBeenCalledOnce(); + expect(zoomToFitStub).toHaveBeenCalledWith(true); + expect(centerStub).toHaveBeenCalledOnce(); + expect(centerStub).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/graph/node-details/competency-node-details.component.spec.ts b/src/test/javascript/spec/component/learning-paths/graph/node-details/competency-node-details.component.spec.ts new file mode 100644 index 000000000000..f68f77d41183 --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/graph/node-details/competency-node-details.component.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../../test.module'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; +import { HttpResponse } from '@angular/common/http'; +import { CompetencyNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component'; +import { Competency, CompetencyProgress, CompetencyTaxonomy } from 'app/entities/competency.model'; +import { CompetencyService } from 'app/course/competencies/competency.service'; +import { CompetencyRingsComponent } from 'app/course/competencies/competency-rings/competency-rings.component'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltipMocksModule } from '../../../../helpers/mocks/directive/ngbTooltipMocks.module'; + +describe('CompetencyNodeDetailsComponent', () => { + let fixture: ComponentFixture; + let comp: CompetencyNodeDetailsComponent; + let competencyService: CompetencyService; + let findByIdStub: jest.SpyInstance; + let competency: Competency; + let competencyProgress: CompetencyProgress; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, NgbTooltipMocksModule], + declarations: [CompetencyNodeDetailsComponent, MockComponent(CompetencyRingsComponent), MockPipe(ArtemisTranslatePipe)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(CompetencyNodeDetailsComponent); + comp = fixture.componentInstance; + competency = new Competency(); + competency.id = 2; + competency.title = 'Some arbitrary title'; + competency.description = 'Some description'; + competency.taxonomy = CompetencyTaxonomy.ANALYZE; + competency.masteryThreshold = 50; + competencyProgress = new CompetencyProgress(); + competencyProgress.progress = 80; + competencyProgress.confidence = 70; + competency.userProgress = [competencyProgress]; + + competencyService = TestBed.inject(CompetencyService); + findByIdStub = jest.spyOn(competencyService, 'findById').mockReturnValue(of(new HttpResponse({ body: competency }))); + comp.courseId = 1; + comp.competencyId = competency.id; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should load competency on init if not present', () => { + fixture.detectChanges(); + expect(findByIdStub).toHaveBeenCalledOnce(); + expect(findByIdStub).toHaveBeenCalledWith(competency.id, 1); + expect(comp.competency).toEqual(competency); + expect(comp.competencyProgress).toEqual(competencyProgress); + }); + + it('should not load competency on init if already present', () => { + comp.competency = competency; + comp.competencyProgress = competencyProgress; + fixture.detectChanges(); + expect(findByIdStub).not.toHaveBeenCalled(); + }); + + it('should default progress to zero if empty', () => { + competency.userProgress = undefined; + fixture.detectChanges(); + expect(findByIdStub).toHaveBeenCalledOnce(); + expect(findByIdStub).toHaveBeenCalledWith(competency.id, 1); + expect(comp.competency).toEqual(competency); + expect(comp.competencyProgress).toEqual({ confidence: 0, progress: 0 } as CompetencyProgress); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/graph/node-details/exercise-node-details.component.spec.ts b/src/test/javascript/spec/component/learning-paths/graph/node-details/exercise-node-details.component.spec.ts new file mode 100644 index 000000000000..7469f93f790d --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/graph/node-details/exercise-node-details.component.spec.ts @@ -0,0 +1,55 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../../test.module'; +import { MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; +import { HttpResponse } from '@angular/common/http'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltipMocksModule } from '../../../../helpers/mocks/directive/ngbTooltipMocks.module'; +import { ExerciseNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/exercise-node-details.component'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { Exercise } from 'app/entities/exercise.model'; +import { TextExercise } from 'app/entities/text-exercise.model'; + +describe('ExerciseNodeDetailsComponent', () => { + let fixture: ComponentFixture; + let comp: ExerciseNodeDetailsComponent; + let exerciseService: ExerciseService; + let findStub: jest.SpyInstance; + let exercise: Exercise; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, NgbTooltipMocksModule], + declarations: [ExerciseNodeDetailsComponent, MockPipe(ArtemisTranslatePipe)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(ExerciseNodeDetailsComponent); + comp = fixture.componentInstance; + exercise = new TextExercise(undefined, undefined); + exercise.id = 1; + exercise.title = 'Some arbitrary title'; + + exerciseService = TestBed.inject(ExerciseService); + findStub = jest.spyOn(exerciseService, 'find').mockReturnValue(of(new HttpResponse({ body: exercise }))); + comp.exerciseId = exercise.id; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should load exercise on init if not present', () => { + fixture.detectChanges(); + expect(findStub).toHaveBeenCalledOnce(); + expect(findStub).toHaveBeenCalledWith(exercise.id); + expect(comp.exercise).toEqual(exercise); + }); + + it('should not load exercise on init if already present', () => { + comp.exercise = exercise; + fixture.detectChanges(); + expect(findStub).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/graph/node-details/lecture-unit-node-details.component.spec.ts b/src/test/javascript/spec/component/learning-paths/graph/node-details/lecture-unit-node-details.component.spec.ts new file mode 100644 index 000000000000..13f47328ff5d --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/graph/node-details/lecture-unit-node-details.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../../test.module'; +import { MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; +import { HttpResponse } from '@angular/common/http'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltipMocksModule } from '../../../../helpers/mocks/directive/ngbTooltipMocks.module'; +import { LectureUnitNodeDetailsComponent } from 'app/course/learning-paths/learning-path-graph/node-details/lecture-unit-node-details.component'; +import { LectureUnitForLearningPathNodeDetailsDTO, LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; +import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; + +describe('LectureUnitNodeDetailsComponent', () => { + let fixture: ComponentFixture; + let comp: LectureUnitNodeDetailsComponent; + let lectureUnitService: LectureUnitService; + let getLectureUnitForLearningPathNodeDetailsStub: jest.SpyInstance; + let lectureUnit: LectureUnitForLearningPathNodeDetailsDTO; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, NgbTooltipMocksModule], + declarations: [LectureUnitNodeDetailsComponent, MockPipe(ArtemisTranslatePipe)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(LectureUnitNodeDetailsComponent); + comp = fixture.componentInstance; + lectureUnit = new LectureUnitForLearningPathNodeDetailsDTO(); + lectureUnit.id = 1; + lectureUnit.name = 'Some arbitrary name'; + lectureUnit.type = LectureUnitType.TEXT; + + lectureUnitService = TestBed.inject(LectureUnitService); + getLectureUnitForLearningPathNodeDetailsStub = jest + .spyOn(lectureUnitService, 'getLectureUnitForLearningPathNodeDetails') + .mockReturnValue(of(new HttpResponse({ body: lectureUnit }))); + comp.lectureUnitId = lectureUnit.id; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should load lecture unit on init if not present', () => { + fixture.detectChanges(); + expect(getLectureUnitForLearningPathNodeDetailsStub).toHaveBeenCalledOnce(); + expect(getLectureUnitForLearningPathNodeDetailsStub).toHaveBeenCalledWith(lectureUnit.id); + expect(comp.lectureUnit).toEqual(lectureUnit); + }); + + it('should not load lecture unit on init if already present', () => { + comp.lectureUnit = lectureUnit; + fixture.detectChanges(); + expect(getLectureUnitForLearningPathNodeDetailsStub).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-modal.component.spec.ts b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-modal.component.spec.ts new file mode 100644 index 000000000000..f0fc95d2572b --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-modal.component.spec.ts @@ -0,0 +1,47 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../test.module'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { MockComponent } from 'ng-mocks'; +import { LearningPathProgressModalComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-modal.component'; +import { LearningPathGraphComponent } from 'app/course/learning-paths/learning-path-graph/learning-path-graph.component'; +import { By } from '@angular/platform-browser'; +import { LearningPathProgressNavComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-nav.component'; +import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; + +describe('LearningPathProgressModalComponent', () => { + let fixture: ComponentFixture; + let comp: LearningPathProgressModalComponent; + let activeModal: NgbActiveModal; + let closeStub: jest.SpyInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, MockComponent(LearningPathGraphComponent), MockComponent(LearningPathProgressNavComponent)], + declarations: [LearningPathProgressModalComponent], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(LearningPathProgressModalComponent); + comp = fixture.componentInstance; + activeModal = TestBed.inject(NgbActiveModal); + closeStub = jest.spyOn(activeModal, 'close'); + fixture.detectChanges(); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should display learning path graph if learning path is present', () => { + comp.courseId = 2; + comp.learningPath = { id: 1 } as LearningPathPageableSearchDTO; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.graph')).nativeElement).toBeTruthy(); + }); + + it('should correctly close modal', () => { + comp.close(); + expect(closeStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-nav.component.spec.ts b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-nav.component.spec.ts new file mode 100644 index 000000000000..f579a9163537 --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/management/learning-path-progress-nav.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../../test.module'; +import { By } from '@angular/platform-browser'; +import { LearningPathProgressNavComponent } from 'app/course/learning-paths/learning-path-management/learning-path-progress-nav.component'; +import { LearningPathPageableSearchDTO } from 'app/entities/competency/learning-path.model'; +import { UserNameAndLoginDTO } from 'app/core/user/user.model'; +import { MockPipe } from 'ng-mocks'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { NgbTooltipMocksModule } from '../../../helpers/mocks/directive/ngbTooltipMocks.module'; + +describe('LearningPathProgressNavComponent', () => { + let fixture: ComponentFixture; + let comp: LearningPathProgressNavComponent; + let onRefreshStub: jest.SpyInstance; + let onCenterViewStub: jest.SpyInstance; + let onCloseStub: jest.SpyInstance; + let learningPath: LearningPathPageableSearchDTO; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, NgbTooltipMocksModule], + declarations: [LearningPathProgressNavComponent, MockPipe(ArtemisTranslatePipe)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(LearningPathProgressNavComponent); + comp = fixture.componentInstance; + onRefreshStub = jest.spyOn(comp.onRefresh, 'emit'); + onCenterViewStub = jest.spyOn(comp.onCenterView, 'emit'); + onCloseStub = jest.spyOn(comp.onClose, 'emit'); + learningPath = new LearningPathPageableSearchDTO(); + learningPath.user = new UserNameAndLoginDTO(); + learningPath.user.name = 'some arbitrary name'; + learningPath.user.login = 'somearbitrarylogin'; + comp.learningPath = learningPath; + fixture.detectChanges(); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should create', () => { + expect(fixture).toBeTruthy(); + expect(comp).toBeTruthy(); + }); + + it('should emit refresh on click', () => { + const button = fixture.debugElement.query(By.css('#refresh-button')); + expect(button).not.toBeNull(); + + button.nativeElement.click(); + expect(onRefreshStub).toHaveBeenCalledOnce(); + }); + + it('should emit center view on click', () => { + const button = fixture.debugElement.query(By.css('#center-button')); + expect(button).not.toBeNull(); + + button.nativeElement.click(); + expect(onCenterViewStub).toHaveBeenCalledOnce(); + }); + + it('should emit close on click', () => { + const button = fixture.debugElement.query(By.css('#close-button')); + expect(button).not.toBeNull(); + + button.nativeElement.click(); + expect(onCloseStub).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts b/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts index 2a69069e42da..56bd1c1bd962 100644 --- a/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts +++ b/src/test/javascript/spec/component/lecture-unit/lecture-unit.service.spec.ts @@ -92,4 +92,9 @@ describe('LectureUnitService', () => { expect(service.getLectureUnitReleaseDate(textUnit)).toEqual(textUnit.releaseDate); expect(service.getLectureUnitReleaseDate(videoUnit)).toEqual(videoUnit.releaseDate); }); + + it('should send a request to the server to get ngx representation of learning path', fakeAsync(() => { + service.getLectureUnitForLearningPathNodeDetails(1).subscribe(); + httpMock.expectOne({ method: 'GET', url: 'api/lecture-units/1/for-learning-path-node-details' }); + })); }); diff --git a/src/test/javascript/spec/directive/sticky-popover.directive.spec.ts b/src/test/javascript/spec/directive/sticky-popover.directive.spec.ts new file mode 100644 index 000000000000..fb62f9d6c7af --- /dev/null +++ b/src/test/javascript/spec/directive/sticky-popover.directive.spec.ts @@ -0,0 +1,74 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { Component, DebugElement } from '@angular/core'; +import { ArtemisTestModule } from '../test.module'; +import { By } from '@angular/platform-browser'; +import { StickyPopoverDirective } from 'app/shared/sticky-popover/sticky-popover.directive'; + +@Component({ + template: '
some content', +}) +class StickyPopoverComponent { + pattern: string; +} + +describe('StickyPopoverDirective', () => { + let fixture: ComponentFixture; + let debugDirective: DebugElement; + let directive: StickyPopoverDirective; + let openStub: jest.SpyInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + declarations: [StickyPopoverDirective, StickyPopoverComponent], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(StickyPopoverComponent); + debugDirective = fixture.debugElement.query(By.directive(StickyPopoverDirective)); + directive = debugDirective.injector.get(StickyPopoverDirective); + openStub = jest.spyOn(directive, 'open'); + fixture.detectChanges(); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should open on hover', fakeAsync(() => { + fixture.whenStable(); + const div = fixture.debugElement.query(By.css('div')); + expect(div).not.toBeNull(); + div.nativeElement.dispatchEvent(new MouseEvent('pointerenter')); + tick(10); + expect(openStub).toHaveBeenCalledOnce(); + expect(directive.isOpen()).toBeTruthy(); + const span = fixture.debugElement.query(By.css('span')); + expect(span).not.toBeNull(); + })); + + it('should display content on hover', fakeAsync(() => { + fixture.whenStable(); + const div = fixture.debugElement.query(By.css('div')); + expect(div).not.toBeNull(); + div.nativeElement.dispatchEvent(new MouseEvent('pointerenter')); + tick(10); + const span = fixture.debugElement.query(By.css('span')); + expect(span).not.toBeNull(); + })); + + it('should close on leave', fakeAsync(() => { + fixture.whenStable(); + const div = fixture.debugElement.query(By.css('div')); + expect(div).not.toBeNull(); + div.nativeElement.dispatchEvent(new MouseEvent('pointerenter')); + tick(10); + let span = fixture.debugElement.query(By.css('span')); + expect(span).not.toBeNull(); + div.nativeElement.dispatchEvent(new MouseEvent('pointerleave')); + tick(100); + span = fixture.debugElement.query(By.css('span')); + expect(span).toBeNull(); + })); +}); diff --git a/src/test/javascript/spec/service/learning-path.service.spec.ts b/src/test/javascript/spec/service/learning-path.service.spec.ts index cbd2601a8266..2375b8c0d377 100644 --- a/src/test/javascript/spec/service/learning-path.service.spec.ts +++ b/src/test/javascript/spec/service/learning-path.service.spec.ts @@ -45,4 +45,10 @@ describe('LearningPathService', () => { expect(getStub).toHaveBeenCalledOnce(); expect(getStub).toHaveBeenCalledWith('api/courses/1/learning-path-health', { observe: 'response' }); }); + + it('should send a request to the server to get ngx representation of learning path', () => { + learningPathService.getLearningPathNgxGraph(1).subscribe(); + expect(getStub).toHaveBeenCalledOnce(); + expect(getStub).toHaveBeenCalledWith('api/learning-path/1/graph', { observe: 'response' }); + }); }); From ccb2ddbb2e1e60958bda061ea5de27d2b9f73aa9 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Thu, 21 Sep 2023 23:41:10 +0200 Subject: [PATCH 5/6] Development: Fix a dependency issues with Bamboo specs --- .../Artemis__Server__LocalVC___LocalCI__IRIS_.xml | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.idea/runConfigurations/Artemis__Server__LocalVC___LocalCI__IRIS_.xml b/.idea/runConfigurations/Artemis__Server__LocalVC___LocalCI__IRIS_.xml index 9e1aa86981de..00e4e1f2b43d 100644 --- a/.idea/runConfigurations/Artemis__Server__LocalVC___LocalCI__IRIS_.xml +++ b/.idea/runConfigurations/Artemis__Server__LocalVC___LocalCI__IRIS_.xml @@ -2,7 +2,7 @@