From f37cb53bacf8c73a85bb90d90e72701b713babec Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Mon, 30 Dec 2024 12:04:26 +0100 Subject: [PATCH 1/3] Development: Improve build.gradle configuration --- build.gradle | 34 +++++++++++++----------------- gradle.properties | 9 ++++---- gradle/liquibase.gradle | 43 ++++++++++++++++++++++---------------- gradle/profile_dev.gradle | 24 ++++++++++----------- gradle/profile_prod.gradle | 20 +++++++++--------- gradle/spotless.gradle | 24 ++++++++++----------- gradle/war.gradle | 6 +++--- settings.gradle | 18 +++++++++------- 8 files changed, 92 insertions(+), 86 deletions(-) diff --git a/build.gradle b/build.gradle index f3d9bce02838..310c4d32143e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,26 +1,27 @@ buildscript { dependencies { - classpath "com.diffplug.spotless:spotless-plugin-gradle:6.25.0" + classpath "com.diffplug.spotless:spotless-plugin-gradle:${spotless_plugin_version}" + // This is required so that the latest version of the liquibase gradle plugin works + classpath "org.liquibase:liquibase-core:${liquibase_version}" } } plugins { id "checkstyle" - id "java" - id "maven-publish" id "idea" id "jacoco" - id "org.springframework.boot" version "${spring_boot_version}" - id "io.spring.dependency-management" version "1.1.7" - id "com.google.cloud.tools.jib" version "3.4.4" - id "com.github.node-gradle.node" version "${gradle_node_plugin_version}" - id "com.diffplug.spotless" version "6.25.0" - // this allows us to find outdated dependencies via ./gradlew dependencyUpdates - id "com.github.ben-manes.versions" version "0.51.0" + id "java" + id "com.adarshr.test-logger" version "4.0.0" + id "com.diffplug.spotless" version "${spotless_plugin_version}" id "com.github.andygoossens.modernizer" version "${modernizer_plugin_version}" + id "com.github.ben-manes.versions" version "0.51.0" + id "com.github.node-gradle.node" version "${gradle_node_plugin_version}" + id "com.google.cloud.tools.jib" version "3.4.4" id "com.gorylenko.gradle-git-properties" version "2.4.2" + id "io.spring.dependency-management" version "1.1.7" + id "org.liquibase.gradle" version "${liquibase_plugin_version}" id "org.owasp.dependencycheck" version "11.1.1" - id "com.adarshr.test-logger" version "4.0.0" + id "org.springframework.boot" version "${spring_boot_version}" } group = "de.tum.cit.aet.artemis" @@ -144,7 +145,7 @@ dependencies { exclude module: "jaxb-api" } - implementation "org.gitlab4j:gitlab4j-api:6.0.0-rc.7" + implementation "org.gitlab4j:gitlab4j-api:6.0.0-rc.8" implementation "de.jplag:jplag:${jplag_version}" @@ -326,7 +327,7 @@ dependencies { implementation "org.bouncycastle:bcpkix-jdk18on:1.79" implementation "org.bouncycastle:bcprov-jdk18on:1.79" - implementation "com.mysql:mysql-connector-j:9.1.0" + implementation "com.mysql:mysql-connector-j:${mysql_version}" implementation "org.postgresql:postgresql:42.7.4" implementation "org.zalando:problem-spring-web:0.29.1" @@ -365,7 +366,7 @@ dependencies { strictly byte_buddy_version } } - liquibase("net.bytebuddy:byte-buddy") { + liquibaseRuntime("net.bytebuddy:byte-buddy") { version { strictly byte_buddy_version } @@ -380,11 +381,6 @@ dependencies { strictly byte_buddy_version } } - liquibase("net.bytebuddy:byte-buddy-agent") { - version { - strictly byte_buddy_version - } - } annotationProcessor "org.hibernate:hibernate-jpamodelgen:${hibernate_version}" annotationProcessor "org.glassfish.jaxb:jaxb-runtime:${jaxb_runtime_version}" diff --git a/gradle.properties b/gradle.properties index 0277f98d2a3f..646b23dcf6fa 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,19 +22,20 @@ fasterxml_version=2.18.2 # TODO: 7.1.0 includes bugs related to git diffs, therefore we cannot update jgit_version=7.0.0.202409031743-r sshd_version=2.14.0 -checkstyle_version=10.21.0 +checkstyle_version=10.21.1 jplag_version=5.1.0 # not really used in Artemis, nor JPlag, nor the used version of Stanford CoreNLP, but we use the latest to avoid security vulnerability warnings # NOTE: we cannot need to use the latest version 9.x or 10.x here as long as Stanford CoreNLP does not reference it lucene_version=8.11.4 slf4j_version=2.0.16 -sentry_version=7.19.0 +sentry_version=7.19.1 liquibase_version=4.30.0 docker_java_version=3.4.1 logback_version=1.5.15 java_parser_version=3.26.2 byte_buddy_version=1.15.11 netty_version=4.1.115.Final +mysql_version=9.1.0 # testing # make sure both versions are compatible @@ -43,12 +44,12 @@ junit_platform_version=1.11.4 mockito_version=5.14.2 testcontainer_version=1.20.4 - # gradle plugin version gradle_node_plugin_version=7.1.0 apt_plugin_version=0.21 -liquibase_plugin_version=2.2.2 +liquibase_plugin_version=3.0.1 modernizer_plugin_version=1.10.0 +spotless_plugin_version=6.25.0 org.gradle.jvmargs=-Xmx2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -Duser.country=US -Duser.language=en \ --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ diff --git a/gradle/liquibase.gradle b/gradle/liquibase.gradle index 8f698a891937..dd48fa3cb338 100644 --- a/gradle/liquibase.gradle +++ b/gradle/liquibase.gradle @@ -1,42 +1,49 @@ import org.gradle.internal.os.OperatingSystem configurations { - liquibase + liquibaseRuntime.extendsFrom sourceSets.main.compileClasspath } dependencies { - liquibase "org.liquibase.ext:liquibase-hibernate6:${liquibase_version}" + implementation "org.liquibase:liquibase-core:${liquibase_version}" + liquibaseRuntime "org.liquibase:liquibase-core:${liquibase_version}" + // Dependency required to parse options. Refer to https://github.com/liquibase/liquibase-gradle-plugin/tree/Release_2.2.0#news. + liquibaseRuntime "info.picocli:picocli:4.7.6" + + liquibaseRuntime "org.liquibase.ext:liquibase-hibernate6:${liquibase_version}" + liquibaseRuntime "com.mysql:mysql-connector-j:${mysql_version}" + liquibaseRuntime "org.liquibase.ext:liquibase-hibernate6:${liquibase_version}" } ext.isWindows = OperatingSystem.current().isWindows() if (isWindows) { - tasks.register('pathingLiquibaseJar', Jar) { + tasks.register("pathingLiquibaseJar", Jar) { dependsOn configurations.liquibase - archiveAppendix = 'pathingLiquibase' + archiveAppendix = "pathingLiquibase" doFirst { manifest { - attributes 'Class-Path': (sourceSets.main.runtimeClasspath + configurations.liquibase).collect { - it.toURI().toURL().toString().replaceFirst(/file:\/+/, '/') - }.join(' ') + attributes "Class-Path": (sourceSets.main.runtimeClasspath + configurations.liquibase).collect { + it.toURI().toURL().toString().replaceFirst(/file:\/+/, "/") + }.join(" ") } } } } -tasks.register('initPaths', { +tasks.register("initPaths", { group = "liquibase" - dependsOn tasks.named('compileJava') + dependsOn tasks.named("compileJava") if (isWindows) { - dependsOn tasks.named('pathingLiquibaseJar') + dependsOn tasks.named("pathingLiquibaseJar") } }) def liquibaseCommand(command) { tasks.register("runLiquibaseCommand", JavaExec) { if (isWindows) { - classpath tasks.named('pathingLiquibaseJar').get().outputs.files + classpath tasks.named("pathingLiquibaseJar").get().outputs.files } else { classpath sourceSets.main.runtimeClasspath classpath configurations.liquibase @@ -57,22 +64,22 @@ def liquibaseCommand(command) { }.get().exec() } -tasks.register('liquibaseDiffChangeLog', { - dependsOn tasks.named('initPaths') +tasks.register("liquibaseDiffChangeLog", { + dependsOn tasks.named("initPaths") doLast { liquibaseCommand("diffChangeLog") } }) -tasks.register('liquibaseClearChecksums', { - dependsOn tasks.named('initPaths') +tasks.register("liquibaseClearChecksums", { + dependsOn tasks.named("initPaths") doLast { liquibaseCommand("clearChecksums") } }) -tasks.register('liquibaseGenerateChangelog', { - dependsOn tasks.named('initPaths') +tasks.register("liquibaseGenerateChangelog", { + dependsOn tasks.named("initPaths") doLast { liquibaseCommand("generateChangeLog") } @@ -80,6 +87,6 @@ tasks.register('liquibaseGenerateChangelog', { static def buildTimestamp() { def date = new Date() - def formattedDate = date.format('yyyyMMddHHmmss') + def formattedDate = date.format("yyyyMMddHHmmss") return formattedDate } diff --git a/gradle/profile_dev.gradle b/gradle/profile_dev.gradle index b67d2c867b83..2321a4ce5716 100644 --- a/gradle/profile_dev.gradle +++ b/gradle/profile_dev.gradle @@ -12,12 +12,12 @@ dependencies { } */ -def profiles = 'dev' -if (project.hasProperty('no-liquibase')) { - profiles += ',no-liquibase' +def profiles = "dev" +if (project.hasProperty("no-liquibase")) { + profiles += ",no-liquibase" } -if (project.hasProperty('tls')) { - profiles += ',tls' +if (project.hasProperty("tls")) { + profiles += ",tls" } springBoot { @@ -36,16 +36,16 @@ bootJar { duplicatesStrategy = DuplicatesStrategy.EXCLUDE } -tasks.register('webapp', NpmTask) { - inputs.property('appVersion', project.version) +tasks.register("webapp", NpmTask) { + inputs.property("appVersion", project.version) inputs.files("package-lock.json") - .withPropertyName('package-lock') + .withPropertyName("package-lock") .withPathSensitivity(PathSensitivity.RELATIVE) inputs.files("build.gradle") - .withPropertyName('build.gradle') + .withPropertyName("build.gradle") .withPathSensitivity(PathSensitivity.RELATIVE) inputs.files("angular.json") - .withPropertyName('angular.json') + .withPropertyName("angular.json") .withPathSensitivity(PathSensitivity.RELATIVE) inputs.files("tsconfig.json", "tsconfig.app.json") .withPropertyName("tsconfig") @@ -63,8 +63,8 @@ tasks.register('webapp', NpmTask) { } processResources { - inputs.property('version', version) - inputs.property('springProfiles', profiles) + inputs.property("version", version) + inputs.property("springProfiles", profiles) filesMatching("**/application.yml") { filter { it.replace("#project.version#", version) diff --git a/gradle/profile_prod.gradle b/gradle/profile_prod.gradle index fa5b5be6e062..0814b9c267e1 100644 --- a/gradle/profile_prod.gradle +++ b/gradle/profile_prod.gradle @@ -1,6 +1,6 @@ -def profiles = 'prod' -if (project.hasProperty('no-liquibase')) { - profiles += ',no-liquibase' +def profiles = "prod" +if (project.hasProperty("no-liquibase")) { + profiles += ",no-liquibase" } springBoot { @@ -11,26 +11,26 @@ bootRun { args = [] } -tasks.register('webapp_test', NpmTask) { +tasks.register("webapp_test", NpmTask) { dependsOn "npm_install" args = ["run", "webapp:test"] } -tasks.register('webapp', NpmTask) { +tasks.register("webapp", NpmTask) { dependsOn "npm_install" args = ["run", "webapp:prod"] environment = [APP_VERSION: project.version] } processResources { - inputs.property('version', version) - inputs.property('springProfiles', profiles) - filesMatching('**/application.yml') { + inputs.property("version", version) + inputs.property("springProfiles", profiles) + filesMatching("**/application.yml") { filter { - it.replace('#project.version#', version) + it.replace("#project.version#", version) } filter { - it.replace('#spring.profiles.active#', profiles) + it.replace("#spring.profiles.active#", profiles) } } } diff --git a/gradle/spotless.gradle b/gradle/spotless.gradle index 136a30f1c0b0..30ad56cd4fca 100644 --- a/gradle/spotless.gradle +++ b/gradle/spotless.gradle @@ -27,38 +27,38 @@ spotless { removeUnusedImports() - custom 'Remove commented-out import statements', { - it.replaceAll(/\n\/\/ import .*?;.*/, '') + custom "Remove commented-out import statements", { + it.replaceAll(/\n\/\/ import .*?;.*/, "") } - custom 'Refuse wildcard imports', { code -> - // Wildcard imports can't be resolved by spotless itself. + custom "Refuse wildcard imports", { code -> + // Wildcard imports cannot be resolved by spotless itself. // This will require the developer themselves to adhere to best practices. if (code =~ /\nimport .*\*;/) { - throw new IllegalArgumentException("Do not use wildcard imports. 'spotlessApply' cannot resolve this issue.") + throw new IllegalArgumentException("Do not use wildcard imports. \"spotlessApply\" cannot resolve this issue.") } return code // make sure to avoid a warning and always } - custom 'Remove unhelpful javadoc stubs', { + custom "Remove unhelpful javadoc stubs", { // e.g., remove the following lines: // "* @param paramName" // "* @throws ExceptionType" - // "* @return returnType"' + // "* @return returnType"" // Multiline to allow anchors on newlines - it.replaceAll(/(?m)^ *\* *@(?:param|throws|return) *\w* *\n/, '') + it.replaceAll(/(?m)^ *\* *@(?:param|throws|return) *\w* *\n/, "") } - custom 'Remove any empty Javadocs and block comments', { + custom "Remove any empty Javadocs and block comments", { // Matches any /** [...] */ or /* [...] */ that contains: // (a) only whitespace // (b) trivial information, such as "@param paramName" or @throws ExceptionType // without any additional information. This information is implicit in the signature. - it.replaceAll(/\/\*+\s*\n(\s*\*\s*\n)*\s*\*+\/\s*\n/, '') + it.replaceAll(/\/\*+\s*\n(\s*\*\s*\n)*\s*\*+\/\s*\n/, "") } // Enforce style modifier order - custom 'Modifier ordering', { + custom "Modifier ordering", { def modifierRanking = [ "public" : 1, "protected" : 2, @@ -78,7 +78,7 @@ spotless { // Do not replace the leading non-word character. Identify the modifiers it.replaceAll(/(?:public |protected |private |abstract |default |static |final |transient |volatile |synchronized |native |strictfp ){2,}/, { // Sort the modifiers according to the ranking above - it.split().sort({ modifierRanking[it] }).join(' ') + ' ' + it.split().sort({ modifierRanking[it] }).join(" ") + " " } ) } diff --git a/gradle/war.gradle b/gradle/war.gradle index 7daba4b40242..ae06b58d9e33 100644 --- a/gradle/war.gradle +++ b/gradle/war.gradle @@ -1,12 +1,12 @@ apply plugin: "war" bootWar { - mainClass = 'de.tum.cit.aet.artemis.ArtemisApp' + mainClass = "de.tum.cit.aet.artemis.ArtemisApp" includes = ["WEB-INF/**", "META-INF/**"] webXml = file("${project.rootDir}/src/main/webapp/WEB-INF/web.xml") manifest { - attributes('Implementation-Title': 'Artemis', - 'Implementation-Version': version) + attributes("Implementation-Title": "Artemis", + "Implementation-Version": version) } duplicatesStrategy = DuplicatesStrategy.EXCLUDE } diff --git a/settings.gradle b/settings.gradle index 4079e5a4cc6e..7c39fae5a227 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,18 +1,20 @@ import org.apache.tools.ant.DirectoryScanner pluginManagement { + repositories { + mavenLocal() + mavenCentral() + gradlePluginPortal() + } plugins { - id 'org.springframework.boot' version "${spring_boot_version}" - id 'com.github.node-gradle.node' version "${gradle_node_plugin_version}" - id 'org.liquibase.gradle' version "${liquibase_plugin_version}" - id 'net.ltgt.apt-eclipse' version "${apt_plugin_version}" - id 'net.ltgt.apt-idea' version "${apt_plugin_version}" - id 'net.ltgt.apt' version "${apt_plugin_version}" - id 'com.github.andygoossens.gradle-modernizer-plugin' version "${modernizer_plugin_version}" + id "org.liquibase.gradle" version "${liquibase_plugin_version}" + id "net.ltgt.apt-eclipse" version "${apt_plugin_version}" + id "net.ltgt.apt-idea" version "${apt_plugin_version}" + id "net.ltgt.apt" version "${apt_plugin_version}" } } -rootProject.name = 'Artemis' +rootProject.name = "Artemis" // needed for programming exercise templates DirectoryScanner.removeDefaultExclude "**/.gitattributes" From 63ff1fbce6351921826616dc8be9c05099d02a84 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Tue, 31 Dec 2024 18:05:42 +0100 Subject: [PATCH 2/3] Communication: Fix performance issues in mark all read feature (#10083) --- .../domain/conversation/Channel.java | 23 +++---- .../artemis/communication/dto/ChannelDTO.java | 49 ++++++++------ .../communication/dto/ConversationDTO.java | 2 + .../ConversationParticipantRepository.java | 20 ++++++ .../conversation/ConversationRepository.java | 21 ++++-- .../service/conversation/ChannelService.java | 38 +++-------- .../conversation/ConversationService.java | 64 ++++++++----------- .../auth/ChannelAuthorizationService.java | 6 +- .../SingleUserNotificationService.java | 1 + .../web/conversation/ChannelResource.java | 24 ++----- .../metis/conversation/channel.model.ts | 20 +++--- ...feedback-detail-channel-modal.component.ts | 4 +- .../channels-create-dialog.component.ts | 4 +- .../conversation-member-row.component.ts | 12 ++-- .../metis/conversations/channel.service.ts | 23 +++++-- .../conversations/conversation.service.ts | 12 +--- .../communication/ChannelIntegrationTest.java | 22 +++---- .../ConversationIntegrationTest.java | 29 ++++----- .../ConversationTestRepository.java | 6 ++ .../course-conversations.component.spec.ts | 8 ++- .../channels-create-dialog.component.spec.ts | 6 +- .../channel-item.component.spec.ts | 5 +- ...channels-overview-dialog.component.spec.ts | 18 +++--- ...versation-add-users-form.component.spec.ts | 26 ++++---- ...rsation-add-users-dialog.component.spec.ts | 6 +- ...nversation-detail-dialog.component.spec.ts | 3 +- .../conversation-info.component.spec.ts | 4 +- .../conversation-member-row.component.spec.ts | 4 +- .../conversation-members.component.spec.ts | 3 +- .../conversation-settings.component.spec.ts | 2 +- .../conversation-header.component.spec.ts | 8 +-- .../conversation-messages.component.spec.ts | 6 +- .../services/channel.service.spec.ts | 2 +- .../conversation-permissions.util.spec.ts | 36 ++++++----- .../services/conversation.service.spec.ts | 3 +- .../metis-conversation.service.spec.ts | 11 ++-- .../feedback-analysis.service.spec.ts | 3 +- ...ack-detail-channel-modal.component.spec.ts | 8 +-- .../conversation-options.component.spec.ts | 8 +-- .../spec/service/notification.service.spec.ts | 4 +- 40 files changed, 281 insertions(+), 273 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/conversation/Channel.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/conversation/Channel.java index e37beff7d4b7..4b69920ee2d1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/conversation/Channel.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/conversation/Channel.java @@ -57,16 +57,14 @@ public class Channel extends Conversation { * A channel is either public or private. Users need an invitation to join a private channel. Every user can join a public channel. */ @Column(name = "is_public") - @NotNull - private Boolean isPublic; + private boolean isPublic = false; /** * An announcement channel is a special type of channel where only channel moderators and instructors can start new posts. * Answer posts are still possible so that students can ask questions concerning the announcement. */ @Column(name = "is_announcement") - @NotNull - private Boolean isAnnouncementChannel; + private boolean isAnnouncementChannel = false; /** * A channel that is no longer needed can be archived or deleted. @@ -74,8 +72,7 @@ public class Channel extends Conversation { * The channel can be unarchived at any time. */ @Column(name = "is_archived") - @NotNull - private Boolean isArchived; + private boolean isArchived = false; /** * Channels, that are meant to be seen by all course members by default, even if they haven't joined the channel yet, can be flagged with is_course_wide=true. @@ -101,7 +98,7 @@ public class Channel extends Conversation { private Exam exam; public Channel(Long id, User creator, Set conversationParticipants, Set posts, Course course, ZonedDateTime creationDate, - ZonedDateTime lastMessageDate, String name, @Nullable String description, @Nullable String topic, Boolean isPublic, Boolean isAnnouncementChannel, Boolean isArchived, + ZonedDateTime lastMessageDate, String name, @Nullable String description, @Nullable String topic, boolean isPublic, boolean isAnnouncementChannel, boolean isArchived, boolean isCourseWide, Lecture lecture, Exercise exercise, Exam exam) { super(id, creator, conversationParticipants, posts, course, creationDate, lastMessageDate); this.name = name; @@ -138,11 +135,11 @@ public void setDescription(@Nullable String description) { } @Nullable - public Boolean getIsPublic() { + public boolean getIsPublic() { return isPublic; } - public void setIsPublic(@Nullable Boolean isPublic) { + public void setIsPublic(boolean isPublic) { this.isPublic = isPublic; } @@ -155,19 +152,19 @@ public void setTopic(@Nullable String topic) { this.topic = topic; } - public Boolean getIsArchived() { + public boolean getIsArchived() { return isArchived; } - public void setIsArchived(Boolean archived) { + public void setIsArchived(boolean archived) { isArchived = archived; } - public Boolean getIsAnnouncementChannel() { + public boolean getIsAnnouncementChannel() { return isAnnouncementChannel; } - public void setIsAnnouncementChannel(Boolean announcementChannel) { + public void setIsAnnouncementChannel(boolean announcementChannel) { isAnnouncementChannel = announcementChannel; } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/ChannelDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/ChannelDTO.java index 338a29b2b421..6e5738d3904a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/dto/ChannelDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/ChannelDTO.java @@ -6,6 +6,7 @@ import de.tum.cit.aet.artemis.communication.domain.conversation.ChannelSubType; @JsonInclude(JsonInclude.Include.NON_EMPTY) +// TODO: use record in the future public class ChannelDTO extends ConversationDTO { private String name; @@ -14,25 +15,25 @@ public class ChannelDTO extends ConversationDTO { private String topic; - private Boolean isPublic; + private boolean isPublic = false; // default value - private Boolean isAnnouncementChannel; + private boolean isAnnouncementChannel = false; // default value - private Boolean isArchived; + private boolean isArchived = false; // default value - private Boolean isCourseWide; + private boolean isCourseWide = false; // default value // property not taken from entity /** * A course instructor has channel moderation rights but is not necessarily a moderator of the channel */ - private Boolean hasChannelModerationRights; + private boolean hasChannelModerationRights = false; // default value // property not taken from entity /** * Member of the channel that is also a moderator of the channel */ - private Boolean isChannelModerator; + private boolean isChannelModerator = false; // default value // property not taken from entity /** @@ -98,43 +99,43 @@ public void setTopic(String topic) { this.topic = topic; } - public Boolean getIsPublic() { + public boolean getIsPublic() { return isPublic; } - public void setIsPublic(Boolean isPublic) { + public void setIsPublic(boolean isPublic) { this.isPublic = isPublic; } - public Boolean getIsAnnouncementChannel() { + public boolean getIsAnnouncementChannel() { return isAnnouncementChannel; } - public void setIsAnnouncementChannel(Boolean announcementChannel) { + public void setIsAnnouncementChannel(boolean announcementChannel) { isAnnouncementChannel = announcementChannel; } - public Boolean getIsArchived() { + public boolean getIsArchived() { return isArchived; } - public void setIsArchived(Boolean archived) { + public void setIsArchived(boolean archived) { isArchived = archived; } - public Boolean getHasChannelModerationRights() { + public boolean getHasChannelModerationRights() { return hasChannelModerationRights; } - public void setHasChannelModerationRights(Boolean hasChannelModerationRights) { + public void setHasChannelModerationRights(boolean hasChannelModerationRights) { this.hasChannelModerationRights = hasChannelModerationRights; } - public Boolean getIsChannelModerator() { + public boolean getIsChannelModerator() { return isChannelModerator; } - public void setIsChannelModerator(Boolean isChannelModerator) { + public void setIsChannelModerator(boolean isChannelModerator) { this.isChannelModerator = isChannelModerator; } @@ -162,11 +163,11 @@ public Long getSubTypeReferenceId() { return subTypeReferenceId; } - public Boolean getIsCourseWide() { + public boolean getIsCourseWide() { return isCourseWide; } - public void setIsCourseWide(Boolean courseWide) { + public void setIsCourseWide(boolean courseWide) { isCourseWide = courseWide; } @@ -195,4 +196,16 @@ else if (channel.getExam() != null) { this.subType = ChannelSubType.GENERAL; } } + + public Channel toChannel() { + Channel channel = new Channel(); + channel.setName(this.name); + channel.setDescription(this.description); + channel.setTopic(this.topic); + channel.setIsPublic(this.isPublic); + channel.setIsArchived(this.isArchived); + channel.setIsAnnouncementChannel(this.isAnnouncementChannel); + channel.setIsCourseWide(this.isCourseWide); + return channel; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/ConversationDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/ConversationDTO.java index 8ea2d6450ce9..e6500c781cf5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/dto/ConversationDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/ConversationDTO.java @@ -39,6 +39,8 @@ public class ConversationDTO { // property not taken from entity private Long unreadMessagesCount; + // TODO: use boolean where possible + // property not taken from entity private Boolean isFavorite; diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationParticipantRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationParticipantRepository.java index aaf733332105..b56981186c0d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationParticipantRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationParticipantRepository.java @@ -3,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.time.ZonedDateTime; +import java.util.List; import java.util.Optional; import java.util.Set; @@ -60,8 +61,27 @@ public interface ConversationParticipantRepository extends ArtemisJpaRepository< """) void updateLastReadAsync(@Param("userId") Long userId, @Param("conversationId") Long conversationId, @Param("now") ZonedDateTime now); + @Async + @Transactional // ok because of modifying query + @Modifying + @Query(""" + UPDATE ConversationParticipant p + SET p.lastRead = :now, p.unreadMessagesCount = 0 + WHERE p.user.id = :userId + AND p.conversation.id IN :conversationIds + """) + void updateMultipleLastReadAsync(@Param("userId") Long userId, @Param("conversationIds") List conversationIds, @Param("now") ZonedDateTime now); + boolean existsByConversationIdAndUserId(Long conversationId, Long userId); + @Query(""" + SELECT DISTINCT conversationParticipant.conversation.id + FROM ConversationParticipant conversationParticipant + WHERE conversationParticipant.user.id = :userId + AND conversationParticipant.conversation.course.id = :courseId + """) + List findConversationIdsByUserIdAndCourseId(@Param("userId") Long userId, @Param("courseId") Long courseId); + Optional findConversationParticipantByConversationIdAndUserId(Long conversationId, Long userId); @Query(""" diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java index df3782a95a22..e62965d22674 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java @@ -14,6 +14,7 @@ import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; import de.tum.cit.aet.artemis.communication.dto.GeneralConversationInfo; import de.tum.cit.aet.artemis.communication.dto.UserConversationInfo; @@ -90,11 +91,17 @@ SELECT COUNT(p.id) > 0 """) boolean userHasUnreadMessageInCourse(@Param("courseId") Long courseId, @Param("userId") Long userId); - /** - * Retrieves a list of conversations for the given course - * - * @param courseId the course id - * @return a list of conversations for the given course - */ - List findAllByCourseId(Long courseId); + @Query(""" + SELECT DISTINCT c + FROM Conversation c + WHERE c.course.id = :courseId + AND TYPE(c) = Channel + AND c.isCourseWide = TRUE + AND c.id NOT IN ( + SELECT cp.conversation.id + FROM ConversationParticipant cp + WHERE cp.user.id = :userId + ) + """) + List findAllCourseWideChannelsByUserIdAndCourseIdWithoutConversationParticipant(@Param("courseId") Long courseId, @Param("userId") Long userId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java index d16a17430138..8ce8c73a9364 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java @@ -256,14 +256,12 @@ public void unarchiveChannel(Long channelId) { * * @param lecture the lecture to create the channel for * @param channelName the name of the channel - * @return the created channel */ - public Channel createLectureChannel(Lecture lecture, Optional channelName) { + public void createLectureChannel(Lecture lecture, Optional channelName) { Channel channelToCreate = createDefaultChannel(channelName, "lecture-", lecture.getTitle()); channelToCreate.setLecture(lecture); Channel createdChannel = createChannel(lecture.getCourse(), channelToCreate, Optional.of(userRepository.getUserWithGroupsAndAuthorities())); lecture.setChannelName(createdChannel.getName()); - return createdChannel; } /** @@ -287,14 +285,12 @@ public Channel createExerciseChannel(Exercise exercise, Optional channel * * @param exam the exam to create the channel for * @param channelName the name of the channel - * @return the created channel */ - public Channel createExamChannel(Exam exam, Optional channelName) { + public void createExamChannel(Exam exam, Optional channelName) { Channel channelToCreate = createDefaultChannel(channelName, "exam-", exam.getTitle()); channelToCreate.setExam(exam); Channel createdChannel = createChannel(exam.getCourse(), channelToCreate, Optional.of(userRepository.getUserWithGroupsAndAuthorities())); exam.setChannelName(createdChannel.getName()); - return createdChannel; } /** @@ -302,17 +298,16 @@ public Channel createExamChannel(Exam exam, Optional channelName) { * * @param originalLecture the original lecture * @param channelName the new channel name - * @return the updated channel */ - public Channel updateLectureChannel(Lecture originalLecture, String channelName) { + public void updateLectureChannel(Lecture originalLecture, String channelName) { if (channelName == null) { - return null; + return; } Channel channel = channelRepository.findChannelByLectureId(originalLecture.getId()); if (channel == null) { - return null; + return; } - return updateChannelName(channel, channelName); + updateChannelName(channel, channelName); } /** @@ -428,18 +423,11 @@ private static String generateChannelNameFromTitle(@NotNull String prefix, Optio * @throws BadRequestAlertException if the channel name starts with an invalid prefix (e.g., "$"). */ public Channel createFeedbackChannel(Course course, Long exerciseId, ChannelDTO channelDTO, List feedbackDetailTexts, String testCaseName, User requestingUser) { - Channel channelToCreate = new Channel(); - channelToCreate.setName(channelDTO.getName()); - channelToCreate.setIsPublic(channelDTO.getIsPublic()); - channelToCreate.setIsAnnouncementChannel(channelDTO.getIsAnnouncementChannel()); - channelToCreate.setIsArchived(false); - channelToCreate.setDescription(channelDTO.getDescription()); - - if (channelToCreate.getName() != null && channelToCreate.getName().trim().startsWith("$")) { + if (channelDTO.getName() != null && channelDTO.getName().trim().startsWith("$")) { throw new BadRequestAlertException("User generated channels cannot start with $", "channel", "channelNameInvalid"); } - Channel createdChannel = createChannel(course, channelToCreate, Optional.of(requestingUser)); + Channel createdChannel = createChannel(course, channelDTO.toChannel(), Optional.of(requestingUser)); List userLogins = studentParticipationRepository.findAffectedLoginsByFeedbackDetailText(exerciseId, feedbackDetailTexts, testCaseName); @@ -451,14 +439,4 @@ public Channel createFeedbackChannel(Course course, Long exerciseId, ChannelDTO return createdChannel; } - - /** - * Marks all channels of a course as read for the requesting user. - * - * @param course the course for which all channels should be marked as read. - * @param requestingUser the user requesting the marking of all channels as read. - */ - public void markAllChannelsOfCourseAsRead(Course course, User requestingUser) { - conversationService.markAllConversationOfAUserAsRead(course.getId(), requestingUser); - } } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java index 56afa4a5c497..6dd36e5e9bac 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java @@ -14,6 +14,8 @@ import jakarta.validation.constraints.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -44,11 +46,14 @@ import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.core.util.TimeLogUtil; @Profile(PROFILE_CORE) @Service public class ConversationService { + private static final Logger log = LoggerFactory.getLogger(ConversationService.class); + private static final String METIS_WEBSOCKET_CHANNEL_PREFIX = "/topic/metis/"; private final ConversationDTOService conversationDTOService; @@ -111,18 +116,6 @@ public boolean isMember(Long conversationId, Long userId) { return conversationParticipantRepository.existsByConversationIdAndUserId(conversationId, userId); } - /** - * Checks if a user is a member of a conversation and therefore can access it else throws an exception - * - * @param conversationId the id of the conversation - * @param userId the id of the user - */ - public void isMemberElseThrow(Long conversationId, Long userId) { - if (!isMember(conversationId, userId)) { - throw new AccessForbiddenException("User not allowed to access this conversation!"); - } - } - /** * Checks whether the user is a member of the conversation. *

@@ -284,17 +277,6 @@ public void notifyAllConversationMembersAboutUpdate(Conversation conversation) { broadcastOnConversationMembershipChannel(conversation.getCourse(), MetisCrudAction.UPDATE, conversation, usersToContact); } - /** - * Notify all members of a conversation about a new message in the conversation - * - * @param course the course in which the conversation takes place - * @param conversation conversation which members to notify about the new message (except the author) - * @param recipients users to which the notification should be sent - */ - public void notifyAllConversationMembersAboutNewMessage(Course course, Conversation conversation, Set recipients) { - broadcastOnConversationMembershipChannel(course, MetisCrudAction.NEW_MESSAGE, conversation, recipients); - } - /** * Removes users from a conversation * @@ -335,6 +317,7 @@ public void deleteConversation(Conversation conversation) { * @param conversation the conversation that was affected * @param recipients the users to be messaged */ + // TODO: this should be Async public void broadcastOnConversationMembershipChannel(Course course, MetisCrudAction metisCrudAction, Conversation conversation, Set recipients) { String conversationParticipantTopicName = getConversationParticipantTopicName(course.getId()); recipients.forEach(user -> sendToConversationMembershipChannel(metisCrudAction, conversation, user, conversationParticipantTopicName)); @@ -445,27 +428,36 @@ public void setIsMuted(Long conversationId, User requestingUser, boolean isMuted } /** - * Mark all conversation of a user as read + * Mark all conversation of a user as read in the given course * * @param courseId the id of the course * @param requestingUser the user that wants to mark the conversation as read */ public void markAllConversationOfAUserAsRead(Long courseId, User requestingUser) { - List conversations = conversationRepository.findAllByCourseId(courseId); + long start = System.nanoTime(); + // First, update all existing conversation participants with only two database queries ZonedDateTime now = ZonedDateTime.now(); + var userId = requestingUser.getId(); + List conversationIds = conversationParticipantRepository.findConversationIdsByUserIdAndCourseId(userId, courseId); + conversationParticipantRepository.updateMultipleLastReadAsync(userId, conversationIds, now); + + log.debug("Marking all conversations with existing participants as read took {} ms", TimeLogUtil.formatDurationFrom(start)); + start = System.nanoTime(); + + // Then, find all course-wide channels that the user has not yet accessed and create conversation participants for them + List courseWideChannelsWithoutParticipants = conversationRepository.findAllCourseWideChannelsByUserIdAndCourseIdWithoutConversationParticipant(courseId, userId); List participants = new ArrayList<>(); - for (Conversation conversation : conversations) { - boolean userCanBePartOfConversation = conversationParticipantRepository - .findConversationParticipantByConversationIdAndUserId(conversation.getId(), requestingUser.getId()).isPresent() - || (conversation instanceof Channel channel && channel.getIsCourseWide()); - if (userCanBePartOfConversation) { - ConversationParticipant conversationParticipant = getOrCreateConversationParticipant(conversation.getId(), requestingUser); - conversationParticipant.setLastRead(now); - conversationParticipant.setUnreadMessagesCount(0L); - participants.add(conversationParticipant); - } + for (Channel channel : courseWideChannelsWithoutParticipants) { + var newParticipant = ConversationParticipant.createWithDefaultValues(requestingUser, channel); + newParticipant.setUnreadMessagesCount(0L); + newParticipant.setLastRead(now); + participants.add(newParticipant); + } + // save all new conversation participants (i.e. for course-wide channels that the user has not yet accessed) + if (!participants.isEmpty()) { conversationParticipantRepository.saveAll(participants); } + log.debug("Marking all conversations without participants (i.e. creating new ones) as read took {} ms", TimeLogUtil.formatDurationFrom(start)); } /** @@ -482,7 +474,7 @@ public enum ConversationMemberSearchFilters { * @param findAllStudents if true, result includes all users with the student role in the course * @param findAllTutors if true, result includes all users with the tutor role in the course * @param findAllInstructors if true, result includes all users with the instructor role in the course - * @return the list of users found + * @return the set of users found in the database */ public Set findUsersInDatabase(Course course, boolean findAllStudents, boolean findAllTutors, boolean findAllInstructors) { Set users = new HashSet<>(); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/auth/ChannelAuthorizationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/auth/ChannelAuthorizationService.java index 5069a67dd5f4..1dd64366240d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/auth/ChannelAuthorizationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/auth/ChannelAuthorizationService.java @@ -54,7 +54,7 @@ public void isAllowedToCreateChannel(@NotNull Course course, @NotNull User user) * @param user the user that wants to create the message */ public void isAllowedToCreateNewAnswerPostInChannel(@NotNull Channel channel, @NotNull User user) { - var isArchivedChannel = channel.getIsArchived() != null && channel.getIsArchived(); + var isArchivedChannel = channel.getIsArchived(); var userToCheck = getUserIfNecessary(user); if (isArchivedChannel) { throw new AccessForbiddenException("You are not allowed to create a new answer post in an archived channel."); @@ -72,8 +72,8 @@ public void isAllowedToCreateNewAnswerPostInChannel(@NotNull Channel channel, @N * @param user the user that wants to create answer the message */ public void isAllowedToCreateNewPostInChannel(@NotNull Channel channel, @NotNull User user) { - var isAnnouncementChannel = channel.getIsAnnouncementChannel() != null && channel.getIsAnnouncementChannel(); - var isArchivedChannel = channel.getIsArchived() != null && channel.getIsArchived(); + var isAnnouncementChannel = channel.getIsAnnouncementChannel(); + var isArchivedChannel = channel.getIsArchived(); if (isArchivedChannel) { throw new AccessForbiddenException("You are not allowed to create a new post in an archived channel."); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/SingleUserNotificationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/SingleUserNotificationService.java index cf8b1095f38f..4456f377be1e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/SingleUserNotificationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/SingleUserNotificationService.java @@ -391,6 +391,7 @@ public record NewReplyNotificationSubject(AnswerPost answerPost, User user, User * @param responsibleUser the responsibleUser that has registered/removed the user for the conversation * @param notificationType the type of notification to be sent */ + // TODO: this should be Async public void notifyClientAboutConversationCreationOrDeletion(Conversation conversation, User user, User responsibleUser, NotificationType notificationType) { notifyRecipientWithNotificationType(new ConversationNotificationSubject(conversation, user, responsibleUser), notificationType, null, null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java index ed1b4e45d478..f9fcd4a58986 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java @@ -54,7 +54,6 @@ import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastEditorInCourse; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; -import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; import de.tum.cit.aet.artemis.tutorialgroup.service.TutorialGroupChannelManagementService; @Profile(PROFILE_CORE) @@ -84,13 +83,10 @@ public class ChannelResource extends ConversationManagementResource { private final ConversationParticipantRepository conversationParticipantRepository; - private final StudentParticipationRepository studentParticipationRepository; - public ChannelResource(ConversationParticipantRepository conversationParticipantRepository, SingleUserNotificationService singleUserNotificationService, ChannelService channelService, ChannelRepository channelRepository, ChannelAuthorizationService channelAuthorizationService, AuthorizationCheckService authorizationCheckService, ConversationDTOService conversationDTOService, CourseRepository courseRepository, UserRepository userRepository, - ConversationService conversationService, TutorialGroupChannelManagementService tutorialGroupChannelManagementService, - StudentParticipationRepository studentParticipationRepository) { + ConversationService conversationService, TutorialGroupChannelManagementService tutorialGroupChannelManagementService) { super(courseRepository); this.channelService = channelService; this.channelRepository = channelRepository; @@ -102,7 +98,6 @@ public ChannelResource(ConversationParticipantRepository conversationParticipant this.tutorialGroupChannelManagementService = tutorialGroupChannelManagementService; this.singleUserNotificationService = singleUserNotificationService; this.conversationParticipantRepository = conversationParticipantRepository; - this.studentParticipationRepository = studentParticipationRepository; } /** @@ -226,18 +221,11 @@ public ResponseEntity createChannel(@PathVariable Long courseId, @Re checkCommunicationEnabledElseThrow(course); channelAuthorizationService.isAllowedToCreateChannel(course, requestingUser); - var channelToCreate = new Channel(); - channelToCreate.setName(channelDTO.getName()); - channelToCreate.setIsPublic(channelDTO.getIsPublic()); - channelToCreate.setIsAnnouncementChannel(channelDTO.getIsAnnouncementChannel()); - channelToCreate.setIsArchived(false); - channelToCreate.setDescription(channelDTO.getDescription()); - - if (channelToCreate.getName() != null && channelToCreate.getName().trim().startsWith("$")) { + if (channelDTO.getName() != null && channelDTO.getName().trim().startsWith("$")) { throw new BadRequestAlertException("User generated channels cannot start with $", "channel", "channelNameInvalid"); } - var createdChannel = channelService.createChannel(course, channelToCreate, Optional.of(userRepository.getUserWithGroupsAndAuthorities())); + var createdChannel = channelService.createChannel(course, channelDTO.toChannel(), Optional.of(userRepository.getUserWithGroupsAndAuthorities())); return ResponseEntity.created(new URI("/api/channels/" + createdChannel.getId())).body(conversationDTOService.convertChannelToDTO(requestingUser, createdChannel)); } @@ -498,12 +486,12 @@ public ResponseEntity createFeedbackChannel(@PathVariable Long cours } /** - * PUT /api/courses/:courseId/channels/mark-as-read: Marks all channels of a course as read for the current user. + * POST /api/courses/:courseId/channels/mark-as-read: Marks all channels of a course as read for the current user. * * @param courseId the id of the course. * @return ResponseEntity with status 200 (Ok). */ - @PutMapping("{courseId}/channels/mark-as-read") + @PostMapping("{courseId}/channels/mark-as-read") @EnforceAtLeastStudent public ResponseEntity markAllChannelsOfCourseAsRead(@PathVariable Long courseId) { log.debug("REST request to mark all channels of course {} as read", courseId); @@ -511,7 +499,7 @@ public ResponseEntity markAllChannelsOfCourseAsRead(@PathVariable Lo var course = courseRepository.findByIdElseThrow(courseId); checkCommunicationEnabledElseThrow(course); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, requestingUser); - channelService.markAllChannelsOfCourseAsRead(course, requestingUser); + conversationService.markAllConversationOfAUserAsRead(course.getId(), requestingUser); return ResponseEntity.ok().build(); } diff --git a/src/main/webapp/app/entities/metis/conversation/channel.model.ts b/src/main/webapp/app/entities/metis/conversation/channel.model.ts index dbcc9bb3aac1..541e4f411973 100644 --- a/src/main/webapp/app/entities/metis/conversation/channel.model.ts +++ b/src/main/webapp/app/entities/metis/conversation/channel.model.ts @@ -19,10 +19,10 @@ export class Channel extends Conversation { public name?: string; // max 30 characters public description?: string; // max 250 characters public topic?: string; // max 250 characters; - public isPublic?: boolean; - public isAnnouncementChannel?: boolean; - public isArchived?: boolean; - public isCourseWide?: boolean; + public isPublic = false; // default value + public isAnnouncementChannel = false; // default value + public isArchived = false; // default value + public isCourseWide = false; // default value public exercise?: Exercise; public lecture?: Lecture; @@ -42,12 +42,12 @@ export class ChannelDTO extends ConversationDTO { public name?: string; public description?: string; public topic?: string; - public isPublic?: boolean; - public isAnnouncementChannel?: boolean; - public isArchived?: boolean; - public isChannelModerator?: boolean; - public hasChannelModerationRights?: boolean; - public isCourseWide?: boolean; + public isPublic = false; // default value + public isAnnouncementChannel = false; // default value + public isArchived = false; // default value + public isChannelModerator = false; // default value + public hasChannelModerationRights = false; // default value + public isCourseWide = false; // default value public tutorialGroupId?: number; diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts index 12f65c9710f2..5e9577ea889d 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts @@ -40,8 +40,8 @@ export class FeedbackDetailChannelModalComponent { const channelDTO = new ChannelDTO(); channelDTO.name = this.form.get('name')?.value; channelDTO.description = this.form.get('description')?.value; - channelDTO.isPublic = this.form.get('isPublic')?.value; - channelDTO.isAnnouncementChannel = this.form.get('isAnnouncementChannel')?.value; + channelDTO.isPublic = this.form.get('isPublic')?.value || false; + channelDTO.isAnnouncementChannel = this.form.get('isAnnouncementChannel')?.value || false; this.formSubmitted.emit({ channelDto: channelDTO, navigate }); this.closeModal(); diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts index 28d5f0f710d4..35c4de065757 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts +++ b/src/main/webapp/app/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.ts @@ -35,8 +35,8 @@ export class ChannelsCreateDialogComponent extends AbstractDialogComponent { const { name, description, isPublic, isAnnouncementChannel } = formData; this.channelToCreate.name = name ? name.trim() : undefined; this.channelToCreate.description = description ? description.trim() : undefined; - this.channelToCreate.isPublic = isPublic; - this.channelToCreate.isAnnouncementChannel = isAnnouncementChannel; + this.channelToCreate.isPublic = isPublic ?? false; + this.channelToCreate.isAnnouncementChannel = isAnnouncementChannel ?? false; this.close(this.channelToCreate); } } diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.ts b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.ts index 3824645cfab9..9112db6a5021 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.ts +++ b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.ts @@ -30,7 +30,7 @@ import { catchError } from 'rxjs/operators'; export class ConversationMemberRowComponent implements OnInit, OnDestroy { private ngUnsubscribe = new Subject(); - activeConversation = input(); + activeConversation = input.required(); course = input(); changePerformed = output(); conversationMember = input(); @@ -90,13 +90,13 @@ export class ConversationMemberRowComponent implements OnInit, OnDestroy { this.setUserAuthorityIconAndTooltip(); // the creator of a channel can not be removed from the channel this.canBeRemovedFromConversation = !this.isCurrentUser && this.canRemoveUsersFromConversation(this.activeConversation()!); - if (isChannelDTO(this.activeConversation()!)) { + if (isChannelDTO(this.activeConversation())) { // the creator of a channel can not be removed from the channel - this.canBeRemovedFromConversation = this.canBeRemovedFromConversation && !this.isCreator && !(this.activeConversation() as ChannelDTO)!.isCourseWide; - this.canBeGrantedChannelModeratorRole = this.canGrantChannelModeratorRole(this.activeConversation()!) && !this.conversationMember()?.isChannelModerator; + const channelDTO = this.activeConversation() as ChannelDTO; + this.canBeRemovedFromConversation = this.canBeRemovedFromConversation && !this.isCreator && !channelDTO.isCourseWide; + this.canBeGrantedChannelModeratorRole = this.canGrantChannelModeratorRole(channelDTO) && !this.conversationMember()?.isChannelModerator; // the creator of a channel cannot be revoked the channel moderator role - this.canBeRevokedChannelModeratorRole = - this.canRevokeChannelModeratorRole(this.activeConversation()!) && !this.isCreator && !!this.conversationMember()?.isChannelModerator; + this.canBeRevokedChannelModeratorRole = this.canRevokeChannelModeratorRole(channelDTO) && !this.isCreator && !!this.conversationMember()?.isChannelModerator; } }); } diff --git a/src/main/webapp/app/shared/metis/conversations/channel.service.ts b/src/main/webapp/app/shared/metis/conversations/channel.service.ts index 61105215ca1c..a893c9386f3b 100644 --- a/src/main/webapp/app/shared/metis/conversations/channel.service.ts +++ b/src/main/webapp/app/shared/metis/conversations/channel.service.ts @@ -2,16 +2,15 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { ChannelDTO, ChannelIdAndNameDTO } from 'app/entities/metis/conversation/channel.model'; -import { ConversationService } from 'app/shared/metis/conversations/conversation.service'; import { map } from 'rxjs/operators'; import { AccountService } from 'app/core/auth/account.service'; +import { convertDateFromServer } from 'app/utils/date.utils'; @Injectable({ providedIn: 'root' }) export class ChannelService { public resourceUrl = '/api/courses/'; private http = inject(HttpClient); - private conversationService = inject(ConversationService); private accountService = inject(AccountService); getChannelsOfCourse(courseId: number): Observable> { @@ -51,13 +50,11 @@ export class ChannelService { } create(courseId: number, channelDTO: ChannelDTO): Observable> { - return this.http.post(`${this.resourceUrl}${courseId}/channels`, channelDTO, { observe: 'response' }).pipe(map(this.conversationService.convertDateFromServer)); + return this.http.post(`${this.resourceUrl}${courseId}/channels`, channelDTO, { observe: 'response' }).pipe(map(this.convertDateFromServer)); } update(courseId: number, channelId: number, channelDTO: ChannelDTO): Observable> { - return this.http - .put(`${this.resourceUrl}${courseId}/channels/${channelId}`, channelDTO, { observe: 'response' }) - .pipe(map(this.conversationService.convertDateFromServer)); + return this.http.put(`${this.resourceUrl}${courseId}/channels/${channelId}`, channelDTO, { observe: 'response' }).pipe(map(this.convertDateFromServer)); } deregisterUsersFromChannel(courseId: number, channelId: number, logins?: string[]): Observable> { // if no explicit login is give we assume self deregistration @@ -101,4 +98,18 @@ export class ChannelService { const userLogins = logins ? logins : [this.accountService.userIdentity?.login]; return this.http.post(`${this.resourceUrl}${courseId}/channels/${channelId}/revoke-channel-moderator`, userLogins, { observe: 'response' }); } + + public convertDateFromServer = (res: HttpResponse): HttpResponse => { + if (res.body) { + this.convertServerDates(res.body); + } + return res; + }; + + public convertServerDates(conversation: ChannelDTO) { + conversation.creationDate = convertDateFromServer(conversation.creationDate); + conversation.lastMessageDate = convertDateFromServer(conversation.lastMessageDate); + conversation.lastReadDate = convertDateFromServer(conversation.lastReadDate); + return conversation; + } } diff --git a/src/main/webapp/app/shared/metis/conversations/conversation.service.ts b/src/main/webapp/app/shared/metis/conversations/conversation.service.ts index 28cc8e95267d..1705ddd7f168 100644 --- a/src/main/webapp/app/shared/metis/conversations/conversation.service.ts +++ b/src/main/webapp/app/shared/metis/conversations/conversation.service.ts @@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { Conversation, ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; +import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; import { TranslateService } from '@ngx-translate/core'; import { AccountService } from 'app/core/auth/account.service'; import { User } from 'app/core/user/user.model'; @@ -11,7 +11,7 @@ import { isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model import { ConversationUserDTO } from 'app/entities/metis/conversation/conversation-user-dto.model'; import { isOneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat.model'; import { getUserLabel } from 'app/overview/course-conversations/other/conversation.util'; -import { convertDateFromClient, convertDateFromServer } from 'app/utils/date.utils'; +import { convertDateFromServer } from 'app/utils/date.utils'; type EntityArrayResponseType = HttpResponse; @@ -141,12 +141,6 @@ export class ConversationService { return this.http.get(`${this.resourceUrl}${courseId}/code-of-conduct/responsible-users`, { observe: 'response' }); } - public convertDateFromClient = (conversation: Conversation) => ({ - ...conversation, - creationDate: convertDateFromClient(conversation.creationDate), - lastMessageDate: convertDateFromClient(conversation.lastMessageDate), - }); - public convertDateFromServer = (res: HttpResponse): HttpResponse => { if (res.body) { this.convertServerDates(res.body); @@ -184,6 +178,6 @@ export class ConversationService { }; markAllChannelsAsRead(courseId: number) { - return this.http.put(`${this.resourceUrl}${courseId}/channels/mark-as-read`, { observe: 'response' }); + return this.http.post(`${this.resourceUrl}${courseId}/channels/mark-as-read`, { observe: 'response' }); } } diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java index fcdaed430ebd..067b1c4626df 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java @@ -2,12 +2,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Fail.fail; +import static org.awaitility.Awaitility.await; import java.time.ZonedDateTime; import java.util.Arrays; import java.util.HashSet; import java.util.List; -import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -22,13 +22,11 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.util.LinkedMultiValueMap; -import de.tum.cit.aet.artemis.communication.domain.ConversationParticipant; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.dto.ChannelDTO; import de.tum.cit.aet.artemis.communication.dto.ChannelIdAndNameDTO; import de.tum.cit.aet.artemis.communication.dto.FeedbackChannelRequestDTO; import de.tum.cit.aet.artemis.communication.dto.MetisCrudAction; -import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.communication.service.conversation.ConversationService; import de.tum.cit.aet.artemis.communication.util.ConversationUtilService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -78,9 +76,6 @@ class ChannelIntegrationTest extends AbstractConversationTest { @Autowired private ProgrammingExerciseUtilService programmingExerciseUtilService; - @Autowired - private ChannelService channelService; - @BeforeEach @Override void setupTestScenario() throws Exception { @@ -982,9 +977,9 @@ void createFeedbackChannel_asInstructor_shouldCreateChannel() { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void markAllChannelsAsRead() throws Exception { - // ensure there exist atleast two channel with unread messages in the course - ChannelDTO newChannel1 = createChannel(true, "channel1"); - ChannelDTO newChannel2 = createChannel(true, "channel2"); + // ensure there exist at least two channel with unread messages in the course + createChannel(true, "channel1"); + createChannel(true, "channel2"); List channels = channelRepository.findChannelsByCourseId(exampleCourseId); channels.forEach(channel -> { addUsersToConversation(channel.getId(), "instructor1"); @@ -994,13 +989,12 @@ void markAllChannelsAsRead() throws Exception { }); }); - User requestingUser = userTestRepository.getUser(); - request.put("/api/courses/" + exampleCourseId + "/channels/mark-as-read", null, HttpStatus.OK); + User instructor1 = userTestRepository.getUser(); + request.postWithoutLocation("/api/courses/" + exampleCourseId + "/channels/mark-as-read", null, HttpStatus.OK, null); List updatedChannels = channelRepository.findChannelsByCourseId(exampleCourseId); updatedChannels.forEach(channel -> { - Optional conversationParticipant = conversationParticipantRepository.findConversationParticipantByConversationIdAndUserId(channel.getId(), - requestingUser.getId()); - assertThat(conversationParticipant.get().getUnreadMessagesCount()).isEqualTo(0L); + var conversationParticipant = conversationParticipantRepository.findConversationParticipantByConversationIdAndUserId(channel.getId(), instructor1.getId()); + await().untilAsserted(() -> assertThat(conversationParticipant.get().getUnreadMessagesCount()).isZero()); // async db call, so we need to wait }); } diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/ConversationIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/ConversationIntegrationTest.java index ef488a032399..8a592cb503e0 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/ConversationIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/ConversationIntegrationTest.java @@ -42,28 +42,23 @@ class ConversationIntegrationTest extends AbstractConversationTest { private static final String TEST_PREFIX = "cvtest"; - private final TextExerciseUtilService textExerciseUtilService; + @Autowired + private TextExerciseUtilService textExerciseUtilService; - private final ExerciseUtilService exerciseUtilService; + @Autowired + private ExerciseUtilService exerciseUtilService; - private final ExamUtilService examUtilService; + @Autowired + private ExamUtilService examUtilService; - private final LectureUtilService lectureUtilService; + @Autowired + private LectureUtilService lectureUtilService; - private final ConversationUtilService conversationUtilService; + @Autowired + private ConversationUtilService conversationUtilService; private List users = List.of(); - @Autowired - public ConversationIntegrationTest(TextExerciseUtilService textExerciseUtilService, ExerciseUtilService exerciseUtilService, ExamUtilService examUtilService, - LectureUtilService lectureUtilService, ConversationUtilService conversationUtilService) { - this.textExerciseUtilService = textExerciseUtilService; - this.exerciseUtilService = exerciseUtilService; - this.examUtilService = examUtilService; - this.lectureUtilService = lectureUtilService; - this.conversationUtilService = conversationUtilService; - } - @BeforeEach @Override void setupTestScenario() throws Exception { @@ -212,9 +207,7 @@ void getConversationsOfUser_onlyChannelsIfMessagingDisabled() throws Exception { setCourseInformationSharingConfiguration(CourseInformationSharingConfiguration.COMMUNICATION_ONLY); List channels = request.getList("/api/courses/" + exampleCourseId + "/conversations", HttpStatus.OK, ConversationDTO.class); - assertThat(channels).allSatisfy(ch -> { - assertThat(ch).isInstanceOf(ChannelDTO.class); - }); + assertThat(channels).allSatisfy(ch -> assertThat(ch).isInstanceOf(ChannelDTO.class)); // cleanup conversationMessageRepository.deleteById(post.getId()); diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/test_repository/ConversationTestRepository.java b/src/test/java/de/tum/cit/aet/artemis/communication/test_repository/ConversationTestRepository.java index 7beac73ef014..0256a4dbff90 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/test_repository/ConversationTestRepository.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/test_repository/ConversationTestRepository.java @@ -12,5 +12,11 @@ @Primary public interface ConversationTestRepository extends ConversationRepository { + /** + * Retrieves a list of conversations for the given course + * + * @param courseId the course id + * @return a list of conversations for the given course + */ List findAllByCourseId(long courseId); } diff --git a/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts index 7add13f9c64f..a702982bdd36 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts @@ -1,6 +1,7 @@ import { CourseConversationsComponent } from 'app/overview/course-conversations/course-conversations.component'; import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; +import { OneToOneChatDTO } from '../../../../../../main/webapp/app/entities/metis/conversation/one-to-one-chat.model'; import { generateExampleChannelDTO, generateExampleGroupChatDTO, generateOneToOneChatDTO } from './helpers/conversationExampleModels'; import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; import { MetisConversationService } from 'app/shared/metis/metis-conversation.service'; @@ -44,7 +45,12 @@ import { LayoutService } from 'app/shared/breakpoints/layout.service'; import { CustomBreakpointNames } from 'app/shared/breakpoints/breakpoints.service'; import { Posting, PostingType, SavedPostStatus, SavedPostStatusMap } from 'app/entities/metis/posting.model'; -const examples: (ConversationDTO | undefined)[] = [undefined, generateOneToOneChatDTO({}), generateExampleGroupChatDTO({}), generateExampleChannelDTO({})]; +const examples: (ConversationDTO | undefined)[] = [ + undefined, + generateOneToOneChatDTO({} as OneToOneChatDTO), + generateExampleGroupChatDTO({} as GroupChatDTO), + generateExampleChannelDTO({} as ChannelDTO), +]; examples.forEach((activeConversation) => { describe('CourseConversationComponent with ' + (activeConversation?.type || 'no active conversation'), () => { diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts index d8e311cd0703..0d74ed850884 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-create-dialog/channels-create-dialog.component.spec.ts @@ -7,7 +7,7 @@ import { Course } from 'app/entities/course.model'; import { Component, EventEmitter, Output } from '@angular/core'; import { ChannelFormData, ChannelType } from 'app/overview/course-conversations/dialogs/channels-create-dialog/channel-form/channel-form.component'; import { By } from '@angular/platform-browser'; -import { Channel } from 'app/entities/metis/conversation/channel.model'; +import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { initializeDialog } from '../dialog-test-helpers'; @Component({ @@ -81,10 +81,10 @@ describe('ChannelsCreateDialogComponent', () => { }; form.formSubmitted.emit(formData); - const expectedChannel = new Channel(); + const expectedChannel = new ChannelDTO(); expectedChannel.name = formData.name; expectedChannel.description = formData.description; - expectedChannel.isPublic = formData.isPublic; + expectedChannel.isPublic = formData.isPublic!; expect(closeSpy).toHaveBeenCalledOnce(); expect(closeSpy).toHaveBeenCalledWith(expectedChannel); diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-overview-dialog/channel-item/channel-item.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-overview-dialog/channel-item/channel-item.component.spec.ts index 2a2616bfb0d2..1090b60899b2 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-overview-dialog/channel-item/channel-item.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-overview-dialog/channel-item/channel-item.component.spec.ts @@ -3,6 +3,7 @@ import { ChannelItemComponent } from 'app/overview/course-conversations/dialogs/ import { MockComponent, MockPipe } from 'ng-mocks'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { ChannelIconComponent } from 'app/overview/course-conversations/other/channel-icon/channel-icon.component'; +import { ChannelDTO } from '../../../../../../../../../main/webapp/app/entities/metis/conversation/channel.model'; import { generateExampleChannelDTO } from '../../../helpers/conversationExampleModels'; describe('ChannelItemComponent', () => { @@ -10,7 +11,7 @@ describe('ChannelItemComponent', () => { let fixture: ComponentFixture; const canJoinChannel = jest.fn(); const canLeaveConversation = jest.fn(); - const channel = generateExampleChannelDTO({ id: 1 }); + const channel = generateExampleChannelDTO({ id: 1 } as ChannelDTO); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [ChannelItemComponent, MockPipe(ArtemisTranslatePipe), MockComponent(ChannelIconComponent)] }).compileComponents(); @@ -53,7 +54,7 @@ describe('ChannelItemComponent', () => { expect(fixture.nativeElement.querySelector('#deregister' + channel.id)).toBeFalsy(); // change dto to one where not is member - component.channel = generateExampleChannelDTO({ id: 2, isMember: false }); + component.channel = generateExampleChannelDTO({ id: 2, isMember: false } as ChannelDTO); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('#view' + channel.id)).toBeFalsy(); expect(fixture.nativeElement.querySelector('#register' + channel.id)).toBeFalsy(); diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-overview-dialog/channels-overview-dialog.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-overview-dialog/channels-overview-dialog.component.spec.ts index 3cc001fce612..f44fea3aea36 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-overview-dialog/channels-overview-dialog.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/channels-overview-dialog/channels-overview-dialog.component.spec.ts @@ -23,17 +23,15 @@ import { NgbCollapseMocksModule } from '../../../../../helpers/mocks/directive/n template: '', }) class ChannelItemStubComponent { - @Output() - channelAction = new EventEmitter(); - @Input() - channel: ChannelDTO; + @Output() channelAction = new EventEmitter(); + @Input() channel: ChannelDTO; } const examples: ChannelDTO[] = [ - generateExampleChannelDTO({}), - generateExampleChannelDTO({ subType: ChannelSubType.EXERCISE }), - generateExampleChannelDTO({ subType: ChannelSubType.LECTURE }), - generateExampleChannelDTO({ subType: ChannelSubType.EXAM }), + generateExampleChannelDTO({} as ChannelDTO), + generateExampleChannelDTO({ subType: ChannelSubType.EXERCISE } as ChannelDTO), + generateExampleChannelDTO({ subType: ChannelSubType.LECTURE } as ChannelDTO), + generateExampleChannelDTO({ subType: ChannelSubType.EXAM } as ChannelDTO), ]; examples.forEach((exampleChannel) => { @@ -76,8 +74,8 @@ examples.forEach((exampleChannel) => { beforeEach(() => { fixture = TestBed.createComponent(ChannelsOverviewDialogComponent); component = fixture.componentInstance; - channelOne = generateExampleChannelDTO({ id: 1, name: 'one', subType: exampleChannel.subType }); - channelTwo = generateExampleChannelDTO({ id: 2, name: 'two', subType: exampleChannel.subType }); + channelOne = generateExampleChannelDTO({ id: 1, name: 'one', subType: exampleChannel.subType } as ChannelDTO); + channelTwo = generateExampleChannelDTO({ id: 2, name: 'two', subType: exampleChannel.subType } as ChannelDTO); channelService = TestBed.inject(ChannelService); getChannelsOfCourseSpy = jest.spyOn(channelService, 'getChannelsOfCourse').mockReturnValue( of( diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-add-users-dialog/add-users-form/conversation-add-users-form.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-add-users-dialog/add-users-form/conversation-add-users-form.component.spec.ts index 5dba62cd4934..cf12ecb80732 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-add-users-dialog/add-users-form/conversation-add-users-form.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-add-users-dialog/add-users-form/conversation-add-users-form.component.spec.ts @@ -8,14 +8,15 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; import { CourseUsersSelectorComponent } from 'app/shared/course-users-selector/course-users-selector.component'; import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; +import { GroupChatDTO } from '../../../../../../../../../main/webapp/app/entities/metis/conversation/group-chat.model'; import { generateExampleChannelDTO, generateExampleGroupChatDTO } from '../../../helpers/conversationExampleModels'; import { Course } from 'app/entities/course.model'; -import { isChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { ChannelDTO, isChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { By } from '@angular/platform-browser'; import { UserPublicInfoDTO } from 'app/core/user/user.model'; import { TranslateDirective } from 'app/shared/language/translate.directive'; -const examples: ConversationDTO[] = [generateExampleGroupChatDTO({}), generateExampleChannelDTO({})]; +const examples: ConversationDTO[] = [generateExampleGroupChatDTO({} as GroupChatDTO), generateExampleChannelDTO({} as ChannelDTO)]; examples.forEach((activeConversation) => { describe('ConversationAddUsersFormComponent with ' + activeConversation.type, () => { let component: ConversationAddUsersFormComponent; @@ -164,23 +165,22 @@ examples.forEach((activeConversation) => { expect(component.form.valid).toBeTrue(); expect(component.isSubmitPossible).toBeTrue(); } - const clickSubmitButton = (expectSubmitEvent: boolean, expectedFormData?: AddUsersFormData) => { + const clickSubmitButton = async (expectSubmitEvent: boolean, expectedFormData?: AddUsersFormData) => { const submitFormSpy = jest.spyOn(component, 'submitForm'); const submitFormEventSpy = jest.spyOn(component.formSubmitted, 'emit'); const submitButton = fixture.debugElement.nativeElement.querySelector('#submitButton'); submitButton.click(); - return fixture.whenStable().then(() => { - if (expectSubmitEvent) { - expect(submitFormSpy).toHaveBeenCalledOnce(); - expect(submitFormEventSpy).toHaveBeenCalledOnce(); - expect(submitFormEventSpy).toHaveBeenCalledWith(expectedFormData); - } else { - expect(submitFormSpy).not.toHaveBeenCalled(); - expect(submitFormEventSpy).not.toHaveBeenCalled(); - } - }); + await fixture.whenStable(); + if (expectSubmitEvent) { + expect(submitFormSpy).toHaveBeenCalledOnce(); + expect(submitFormEventSpy).toHaveBeenCalledOnce(); + expect(submitFormEventSpy).toHaveBeenCalledWith(expectedFormData); + } else { + expect(submitFormSpy).not.toHaveBeenCalled(); + expect(submitFormEventSpy).not.toHaveBeenCalled(); + } }; }); }); diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-add-users-dialog/conversation-add-users-dialog.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-add-users-dialog/conversation-add-users-dialog.component.spec.ts index f2886be487e9..ce48067e8a2e 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-add-users-dialog/conversation-add-users-dialog.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-add-users-dialog/conversation-add-users-dialog.component.spec.ts @@ -16,8 +16,8 @@ import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { ChannelIconComponent } from 'app/overview/course-conversations/other/channel-icon/channel-icon.component'; import { UserPublicInfoDTO } from 'app/core/user/user.model'; import { By } from '@angular/platform-browser'; -import { isChannelDTO } from 'app/entities/metis/conversation/channel.model'; -import { isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model'; +import { ChannelDTO, isChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { GroupChatDTO, isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model'; import { of } from 'rxjs'; import { HttpResponse } from '@angular/common/http'; @Component({ @@ -34,7 +34,7 @@ class ConversationAddUsersFormStubComponent { @Input() activeConversation: ConversationDTO; } -const examples: ConversationDTO[] = [generateExampleGroupChatDTO({}), generateExampleChannelDTO({})]; +const examples: ConversationDTO[] = [generateExampleGroupChatDTO({} as GroupChatDTO), generateExampleChannelDTO({} as ChannelDTO)]; examples.forEach((activeConversation) => { describe('ConversationAddUsersDialogComponent with ' + activeConversation.type, () => { let component: ConversationAddUsersDialogComponent; diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/conversation-detail-dialog.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/conversation-detail-dialog.component.spec.ts index dc0c8f2325a3..93acf886bdd3 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/conversation-detail-dialog.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/conversation-detail-dialog.component.spec.ts @@ -12,6 +12,7 @@ import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; import { ChannelIconComponent } from 'app/overview/course-conversations/other/channel-icon/channel-icon.component'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ConversationService } from 'app/shared/metis/conversations/conversation.service'; +import { ChannelDTO } from '../../../../../../../../main/webapp/app/entities/metis/conversation/channel.model'; import { generateExampleChannelDTO, generateExampleGroupChatDTO, generateOneToOneChatDTO } from '../../helpers/conversationExampleModels'; import { initializeDialog } from '../dialog-test-helpers'; import { isOneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat.model'; @@ -65,7 +66,7 @@ class ConversationInfoStubComponent { changesPerformed = new EventEmitter(); } -const examples: ConversationDTO[] = [generateOneToOneChatDTO({}), generateExampleGroupChatDTO({}), generateExampleChannelDTO({})]; +const examples: ConversationDTO[] = [generateOneToOneChatDTO({}), generateExampleGroupChatDTO({}), generateExampleChannelDTO({} as ChannelDTO)]; examples.forEach((activeConversation) => { describe('ConversationDetailDialogComponent with ' + activeConversation.type, () => { diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-info/conversation-info.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-info/conversation-info.component.spec.ts index 6f8c5379103d..4ac0f2a9d16a 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-info/conversation-info.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-info/conversation-info.component.spec.ts @@ -20,7 +20,7 @@ import { GenericUpdateTextPropertyDialogComponent } from 'app/overview/course-co import { defaultSecondLayerDialogOptions } from 'app/overview/course-conversations/other/conversation.util'; import { input } from '@angular/core'; -const examples: ConversationDTO[] = [generateOneToOneChatDTO({}), generateExampleGroupChatDTO({}), generateExampleChannelDTO({})]; +const examples: ConversationDTO[] = [generateOneToOneChatDTO({}), generateExampleGroupChatDTO({}), generateExampleChannelDTO({} as ChannelDTO)]; examples.forEach((activeConversation) => { describe('ConversationInfoComponent with ' + activeConversation.type, () => { @@ -36,7 +36,7 @@ examples.forEach((activeConversation) => { name: 'updated', description: 'updated', topic: 'updated', - }); + } as ChannelDTO); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.spec.ts index 59d135ef102f..453e946afb84 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.spec.ts @@ -42,8 +42,8 @@ const currentUserTemplate = { id: 3, login: 'login3', firstName: 'Kaddl3', lastN const examples: ConversationDTO[] = [ generateOneToOneChatDTO({}), generateExampleGroupChatDTO({}), - generateExampleChannelDTO({}), - generateExampleChannelDTO({ isCourseWide: true }), + generateExampleChannelDTO({} as ChannelDTO), + generateExampleChannelDTO({ isCourseWide: true } as ChannelDTO), ]; examples.forEach((activeConversation) => { diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.spec.ts index 0ea2089fa489..7936f973d076 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.spec.ts @@ -11,6 +11,7 @@ import { ItemCountComponent } from 'app/shared/pagination/item-count.component'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { ConversationMemberSearchFilter, ConversationService } from 'app/shared/metis/conversations/conversation.service'; import { AlertService } from 'app/core/util/alert.service'; +import { ChannelDTO } from '../../../../../../../../../../main/webapp/app/entities/metis/conversation/channel.model'; import { generateExampleChannelDTO, generateExampleGroupChatDTO, generateOneToOneChatDTO } from '../../../../helpers/conversationExampleModels'; import { HttpHeaders, HttpResponse } from '@angular/common/http'; import { of } from 'rxjs'; @@ -34,7 +35,7 @@ class ConversationMemberRowStubComponent { @Input() conversationMember: ConversationUserDTO; } -const examples: ConversationDTO[] = [generateOneToOneChatDTO({}), generateExampleGroupChatDTO({}), generateExampleChannelDTO({})]; +const examples: ConversationDTO[] = [generateOneToOneChatDTO({}), generateExampleGroupChatDTO({}), generateExampleChannelDTO({} as ChannelDTO)]; examples.forEach((activeConversation) => { describe('ConversationMembersComponent with ' + activeConversation.type, () => { diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.spec.ts index 85057eddc05d..075c9c1eede4 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.spec.ts @@ -20,7 +20,7 @@ import { defaultSecondLayerDialogOptions } from 'app/overview/course-conversatio import * as ConversationPermissionUtils from 'app/shared/metis/conversations/conversation-permissions.utils'; import { input, runInInjectionContext } from '@angular/core'; -const examples: ConversationDTO[] = [generateExampleGroupChatDTO({}), generateExampleChannelDTO({})]; +const examples: ConversationDTO[] = [generateExampleGroupChatDTO({}), generateExampleChannelDTO({} as ChannelDTO)]; examples.forEach((activeConversation) => { describe('ConversationSettingsComponent with ' + activeConversation.type, () => { diff --git a/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-header/conversation-header.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-header/conversation-header.component.spec.ts index 32644d2b20b5..ae68ef156219 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-header/conversation-header.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-header/conversation-header.component.spec.ts @@ -32,10 +32,10 @@ import { ProfilePictureComponent } from '../../../../../../../../main/webapp/app const examples: ConversationDTO[] = [ generateOneToOneChatDTO({}), generateExampleGroupChatDTO({}), - generateExampleChannelDTO({}), - generateExampleChannelDTO({ subType: ChannelSubType.EXERCISE, subTypeReferenceId: 1 }), - generateExampleChannelDTO({ subType: ChannelSubType.LECTURE, subTypeReferenceId: 1 }), - generateExampleChannelDTO({ subType: ChannelSubType.EXAM, subTypeReferenceId: 1 }), + generateExampleChannelDTO({} as ChannelDTO), + generateExampleChannelDTO({ subType: ChannelSubType.EXERCISE, subTypeReferenceId: 1 } as ChannelDTO), + generateExampleChannelDTO({ subType: ChannelSubType.LECTURE, subTypeReferenceId: 1 } as ChannelDTO), + generateExampleChannelDTO({ subType: ChannelSubType.EXAM, subTypeReferenceId: 1 } as ChannelDTO), ]; examples.forEach((activeConversation) => { describe('ConversationHeaderComponent with' + +(activeConversation instanceof ChannelDTO ? activeConversation.subType + ' ' : '') + activeConversation.type, () => { diff --git a/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts index 815c5d9a4501..eadb4d81e8e1 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts @@ -17,15 +17,15 @@ import { generateExampleChannelDTO, generateExampleGroupChatDTO, generateOneToOn import { Directive, EventEmitter, Input, Output, QueryList } from '@angular/core'; import { By } from '@angular/platform-browser'; import { Course } from 'app/entities/course.model'; -import { getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { ChannelDTO, getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; import dayjs from 'dayjs'; const examples: ConversationDTO[] = [ generateOneToOneChatDTO({}), generateExampleGroupChatDTO({}), - generateExampleChannelDTO({}), - generateExampleChannelDTO({ isAnnouncementChannel: true }), + generateExampleChannelDTO({} as ChannelDTO), + generateExampleChannelDTO({ isAnnouncementChannel: true } as ChannelDTO), ]; @Directive({ diff --git a/src/test/javascript/spec/component/overview/course-conversations/services/channel.service.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/services/channel.service.spec.ts index a3ab41a7c960..a9f2f7ab8df2 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/services/channel.service.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/services/channel.service.spec.ts @@ -31,7 +31,7 @@ describe('ChannelService', () => { service = TestBed.inject(ChannelService); httpMock = TestBed.inject(HttpTestingController); - elemDefault = generateExampleChannelDTO({}); + elemDefault = generateExampleChannelDTO({} as ChannelDTO); }); afterEach(() => { diff --git a/src/test/javascript/spec/component/overview/course-conversations/services/conversation-permissions.util.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/services/conversation-permissions.util.spec.ts index 4fb64728679a..1141d65c67c5 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/services/conversation-permissions.util.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/services/conversation-permissions.util.spec.ts @@ -19,39 +19,41 @@ import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; describe('ConversationPermissionUtils', () => { describe('channels', () => { describe('canCreateNewMessageInConversation', () => { - const channelsWhereNewMessageCanBeCreated = generateExampleChannelDTO({ isMember: true }); + const channelsWhereNewMessageCanBeCreated = generateExampleChannelDTO({ isMember: true } as ChannelDTO); it('can create new message in channel where user is member', () => { expect(canCreateNewMessageInConversation(channelsWhereNewMessageCanBeCreated)).toBeTrue(); }); it('can not create new message in channel where user is not member', () => { - expect(canCreateNewMessageInConversation(generateExampleChannelDTO({ isMember: false }))).toBeFalse(); + expect(canCreateNewMessageInConversation(generateExampleChannelDTO({ isMember: false } as ChannelDTO))).toBeFalse(); }); it('can not create new message in an archived channel', () => { - expect(canCreateNewMessageInConversation(generateExampleChannelDTO({ isArchived: true }))).toBeFalse(); + expect(canCreateNewMessageInConversation(generateExampleChannelDTO({ isArchived: true } as ChannelDTO))).toBeFalse(); }); it('can not create new message in a an announcement channel where user is not moderator', () => { - expect(canCreateNewMessageInConversation(generateExampleChannelDTO({ isMember: false, isAnnouncementChannel: true }))).toBeFalse(); + expect(canCreateNewMessageInConversation(generateExampleChannelDTO({ isMember: false, isAnnouncementChannel: true } as ChannelDTO))).toBeFalse(); }); it('can create new message in a an announcement channel where user is moderator', () => { - expect(canCreateNewMessageInConversation(generateExampleChannelDTO({ isMember: true, isAnnouncementChannel: true, isChannelModerator: true }))).toBeTrue(); + expect( + canCreateNewMessageInConversation(generateExampleChannelDTO({ isMember: true, isAnnouncementChannel: true, isChannelModerator: true } as ChannelDTO)), + ).toBeTrue(); }); it('can create a new message in an announcement channel where user is not moderator but has moderation rights', () => { expect( canCreateNewMessageInConversation( - generateExampleChannelDTO({ isMember: true, isAnnouncementChannel: true, isChannelModerator: false, hasChannelModerationRights: true }), + generateExampleChannelDTO({ isMember: true, isAnnouncementChannel: true, isChannelModerator: false, hasChannelModerationRights: true } as ChannelDTO), ), ).toBeTrue(); }); }); describe('canLeaveConversation', () => { - const channelsThatCanBeLeft = generateExampleChannelDTO({ isMember: true, isCreator: false }); + const channelsThatCanBeLeft = generateExampleChannelDTO({ isMember: true, isCreator: false } as ChannelDTO); it('can leave channel', () => { expect(canLeaveConversation(channelsThatCanBeLeft)).toBeTrue(); }); @@ -70,7 +72,7 @@ describe('ConversationPermissionUtils', () => { }); describe('addUsersToConversation', () => { - const channelWhereUsersCanBeAdded = generateExampleChannelDTO({ hasChannelModerationRights: true, isArchived: false }); + const channelWhereUsersCanBeAdded = generateExampleChannelDTO({ hasChannelModerationRights: true, isArchived: false } as ChannelDTO); it('can add users to channel', () => { expect(canAddUsersToConversation(channelWhereUsersCanBeAdded)).toBeTrue(); @@ -96,7 +98,7 @@ describe('ConversationPermissionUtils', () => { }); describe('removeUsersFromConversation', () => { - const channelsWhereUsersCanBeRemoved = generateExampleChannelDTO({ hasChannelModerationRights: true, isArchived: false, isPublic: false }); + const channelsWhereUsersCanBeRemoved = generateExampleChannelDTO({ hasChannelModerationRights: true, isArchived: false, isPublic: false } as ChannelDTO); it('can remove users to channel', () => { expect(canRemoveUsersFromConversation(channelsWhereUsersCanBeRemoved)).toBeTrue(); @@ -128,7 +130,7 @@ describe('ConversationPermissionUtils', () => { }); describe('canDeleteChannel', () => { const courseWithCorrectRights = { isAtLeastInstructor: true } as Course; - const channelWhereNoModerator = generateExampleChannelDTO({ hasChannelModerationRights: false, isChannelModerator: false, isCreator: false }); + const channelWhereNoModerator = generateExampleChannelDTO({ hasChannelModerationRights: false, isChannelModerator: false, isCreator: false } as ChannelDTO); it('can delete any channel as instructor', () => { expect(canDeleteChannel(courseWithCorrectRights, channelWhereNoModerator)).toBeTrue(); @@ -138,7 +140,7 @@ describe('ConversationPermissionUtils', () => { expect(canDeleteChannel({ isAtLeastInstructor: false, isAtLeastTutor: true } as Course, channelWhereNoModerator)).toBeFalse(); }); - const channelWhereModerator = generateExampleChannelDTO({ hasChannelModerationRights: true, isChannelModerator: true, isCreator: true }); + const channelWhereModerator = generateExampleChannelDTO({ hasChannelModerationRights: true, isChannelModerator: true, isCreator: true } as ChannelDTO); it('can delete self created channel as tutor', () => { expect(canDeleteChannel({ isAtLeastInstructor: false, isAtLeastTutor: true } as Course, channelWhereModerator)).toBeTrue(); }); @@ -149,14 +151,14 @@ describe('ConversationPermissionUtils', () => { isCreator: false, tutorialGroupId: 1, tutorialGroupTitle: 'test', - }); + } as ChannelDTO); it('can not delete tutorial group channel', () => { expect(canDeleteChannel(courseWithCorrectRights, tutorialGroupChannel)).toBeFalse(); }); }); describe('can grant channel moderator role', () => { - const channelWhereRoleCanBeGranted = generateExampleChannelDTO({ hasChannelModerationRights: true }); + const channelWhereRoleCanBeGranted = generateExampleChannelDTO({ hasChannelModerationRights: true } as ChannelDTO); it('can grant moderator role', () => { expect(canGrantChannelModeratorRole(channelWhereRoleCanBeGranted)).toBeTrue(); @@ -168,7 +170,7 @@ describe('ConversationPermissionUtils', () => { }); describe('can revoke channel moderator role', () => { - const channelWhereRoleCanBeRevoked = generateExampleChannelDTO({ hasChannelModerationRights: true }); + const channelWhereRoleCanBeRevoked = generateExampleChannelDTO({ hasChannelModerationRights: true } as ChannelDTO); it('can revoke moderator role', () => { expect(canRevokeChannelModeratorRole(channelWhereRoleCanBeRevoked)).toBeTrue(); @@ -180,7 +182,7 @@ describe('ConversationPermissionUtils', () => { }); describe('can change channel archival state', () => { - const channelThatCanBeArchived = generateExampleChannelDTO({ hasChannelModerationRights: true }); + const channelThatCanBeArchived = generateExampleChannelDTO({ hasChannelModerationRights: true } as ChannelDTO); it('can archive channel', () => { expect(canChangeChannelArchivalState(channelThatCanBeArchived)).toBeTrue(); @@ -192,7 +194,7 @@ describe('ConversationPermissionUtils', () => { }); describe('can change channel properties', () => { - const channelThatCanBeChanged = generateExampleChannelDTO({ hasChannelModerationRights: true, isArchived: false }); + const channelThatCanBeChanged = generateExampleChannelDTO({ hasChannelModerationRights: true, isArchived: false } as ChannelDTO); it('can change channel properties', () => { expect(canChangeChannelProperties(channelThatCanBeChanged)).toBeTrue(); @@ -214,7 +216,7 @@ describe('ConversationPermissionUtils', () => { isArchived: false, hasChannelModerationRights: false, isChannelModerator: false, - }); + } as ChannelDTO); it('can join channel', () => { expect(canJoinChannel(channelThatCanBeJoined)).toBeTrue(); diff --git a/src/test/javascript/spec/component/overview/course-conversations/services/conversation.service.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/services/conversation.service.spec.ts index dc0096d3303e..571a82d5431c 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/services/conversation.service.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/services/conversation.service.spec.ts @@ -2,6 +2,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { map, take } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; +import { ChannelDTO } from '../../../../../../../main/webapp/app/entities/metis/conversation/channel.model'; import { MockAccountService } from '../../../../helpers/mocks/service/mock-account.service'; import { MockTranslateService } from '../../../../helpers/mocks/service/mock-translate.service'; import { AccountService } from 'app/core/auth/account.service'; @@ -147,7 +148,7 @@ describe('ConversationService', () => { // undefined expect(service.getConversationName(undefined)).toBe(''); // channel - const channel = generateExampleChannelDTO({}); + const channel = generateExampleChannelDTO({} as ChannelDTO); expect(service.getConversationName(channel)).toBe(channel.name); // one to one chat const oneToOneChat = generateOneToOneChatDTO({}); diff --git a/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts index 1eb01c92d9cb..d707c727fbe7 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts @@ -12,6 +12,7 @@ import { ChannelService } from 'app/shared/metis/conversations/channel.service'; import { AccountService } from 'app/core/auth/account.service'; import { HttpResponse } from '@angular/common/http'; import { Subject, forkJoin, of } from 'rxjs'; +import { ConversationDTO } from '../../../../../../../main/webapp/app/entities/metis/conversation/conversation.model'; import { generateExampleChannelDTO, generateExampleGroupChatDTO, generateOneToOneChatDTO } from '../helpers/conversationExampleModels'; import { GroupChatDTO } from 'app/entities/metis/conversation/group-chat.model'; import { OneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat.model'; @@ -60,7 +61,7 @@ describe('MetisConversationService', () => { }); groupChat = generateExampleGroupChatDTO({ id: 1 }); oneToOneChat = generateOneToOneChatDTO({ id: 2 }); - channel = generateExampleChannelDTO({ id: 3 }); + channel = generateExampleChannelDTO({ id: 3 } as ChannelDTO); notificationService = TestBed.inject(NotificationService); newOrUpdatedMessageSubject = new Subject(); @@ -195,7 +196,7 @@ describe('MetisConversationService', () => { return new Promise((done) => { metisConversationService.setUpConversationService(course).subscribe({ complete: () => { - const newChannel = generateExampleChannelDTO({ id: 99 }); + const newChannel = generateExampleChannelDTO({ id: 99 } as ChannelDTO); const createChannelSpy = jest.spyOn(channelService, 'create').mockReturnValue(of(new HttpResponse({ body: newChannel }))); const getConversationSpy = jest .spyOn(conversationService, 'getConversationsOfUser') @@ -301,7 +302,7 @@ describe('MetisConversationService', () => { complete: () => { const websocketDTO = new ConversationWebsocketDTO(); websocketDTO.action = MetisPostAction.CREATE; - websocketDTO.conversation = generateExampleChannelDTO({ id: 99 }); + websocketDTO.conversation = generateExampleChannelDTO({ id: 99 } as ChannelDTO); receiveMockSubject.next(websocketDTO); metisConversationService.conversationsOfUser$.subscribe((conversationsOfUser) => { @@ -421,14 +422,14 @@ describe('MetisConversationService', () => { action: MetisPostAction.CREATE, notification: { title: 'title' }, }; - metisConversationService['conversationsOfUser'] = [{ id: 1, unreadMessageCount: 0 } as ChannelDTO]; + metisConversationService['conversationsOfUser'] = [{ id: 1, unreadMessageCount: 0 } as ConversationDTO]; newOrUpdatedMessageSubject.next(postDTO); expect(metisConversationService['conversationsOfUser'][0].unreadMessagesCount).toBe(1); })); it('should mark messages as read', () => { - metisConversationService['conversationsOfUser'] = [{ id: 1, unreadMessageCount: 1 } as ChannelDTO, { id: 2, unreadMessageCount: 1 } as ChannelDTO]; + metisConversationService['conversationsOfUser'] = [{ id: 1, unreadMessageCount: 1 } as ConversationDTO, { id: 2, unreadMessageCount: 1 } as ConversationDTO]; metisConversationService.markAsRead(2); expect(metisConversationService['conversationsOfUser'][1].unreadMessagesCount).toBe(0); }); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts index 54c6d6d5a5e3..7823bdaa24f0 100644 --- a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts @@ -3,6 +3,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { FeedbackAnalysisService, FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; import { provideHttpClient } from '@angular/common/http'; import { SortingOrder } from 'app/shared/table/pageable-table'; +import { ChannelDTO } from '../../../../../../main/webapp/app/entities/metis/conversation/channel.model'; describe('FeedbackAnalysisService', () => { let service: FeedbackAnalysisService; @@ -152,7 +153,7 @@ describe('FeedbackAnalysisService', () => { description: 'Discussion channel for feedback', isPublic: true, isAnnouncementChannel: false, - }; + } as ChannelDTO; const feedbackChannelRequestMock = { channel: channelDtoMock, diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-detail-channel-modal.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-detail-channel-modal.component.spec.ts index 1db978174340..c1f78dee4499 100644 --- a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-detail-channel-modal.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-detail-channel-modal.component.spec.ts @@ -71,13 +71,13 @@ describe('FeedbackDetailChannelModalComponent', () => { creationDate: undefined, creator: undefined, description: 'channelDescription', - hasChannelModerationRights: undefined, + hasChannelModerationRights: false, hasUnreadMessage: undefined, id: undefined, isAnnouncementChannel: false, - isArchived: undefined, - isChannelModerator: undefined, - isCourseWide: undefined, + isArchived: false, + isChannelModerator: false, + isCourseWide: false, isCreator: undefined, isFavorite: undefined, isHidden: undefined, diff --git a/src/test/javascript/spec/component/shared/sidebar/conversation-options.component.spec.ts b/src/test/javascript/spec/component/shared/sidebar/conversation-options.component.spec.ts index e36a883298a8..de008e5098bc 100644 --- a/src/test/javascript/spec/component/shared/sidebar/conversation-options.component.spec.ts +++ b/src/test/javascript/spec/component/shared/sidebar/conversation-options.component.spec.ts @@ -31,10 +31,10 @@ import { provideRouter } from '@angular/router'; const examples: (() => ConversationDTO)[] = [ () => generateOneToOneChatDTO({}), () => generateExampleGroupChatDTO({}), - () => generateExampleChannelDTO({}), - () => generateExampleChannelDTO({ subType: ChannelSubType.EXERCISE, subTypeReferenceId: 1 }), - () => generateExampleChannelDTO({ subType: ChannelSubType.LECTURE, subTypeReferenceId: 1 }), - () => generateExampleChannelDTO({ subType: ChannelSubType.EXAM, subTypeReferenceId: 1 }), + () => generateExampleChannelDTO({} as ChannelDTO), + () => generateExampleChannelDTO({ subType: ChannelSubType.EXERCISE, subTypeReferenceId: 1 } as ChannelDTO), + () => generateExampleChannelDTO({ subType: ChannelSubType.LECTURE, subTypeReferenceId: 1 } as ChannelDTO), + () => generateExampleChannelDTO({ subType: ChannelSubType.EXAM, subTypeReferenceId: 1 } as ChannelDTO), ]; examples.forEach((conversation) => { diff --git a/src/test/javascript/spec/service/notification.service.spec.ts b/src/test/javascript/spec/service/notification.service.spec.ts index 19c5d0600c33..8ad4713b1a11 100644 --- a/src/test/javascript/spec/service/notification.service.spec.ts +++ b/src/test/javascript/spec/service/notification.service.spec.ts @@ -515,9 +515,9 @@ describe('Notification Service', () => { const notification = { author: { id: 2 }, target: 'target', notificationDate: dayjs() } as Notification; const postDTO: MetisPostDTO = { post: { - author: { id: 2 }, + author: { id: 2 } as User, conversation: { type: ConversationType.CHANNEL, isCourseWide: true } as Channel, - answers: [{ author: { id: 1 } }, { author: { id: 2 } }], + answers: [{ author: { id: 1 } as User }, { author: { id: 2 } as User }], } as Post, action: MetisPostAction.UPDATE, notification, From 1fd1813e1ed37fea9b7ee6a730fe06fc63698601 Mon Sep 17 00:00:00 2001 From: Asli Aykan <56061820+asliayk@users.noreply.github.com> Date: Tue, 31 Dec 2024 23:20:05 +0300 Subject: [PATCH 3/3] Communication: Add recents section to sidebar (#10033) --- .../course-conversations.component.html | 1 - .../course-conversations.component.ts | 26 +++---- .../app/overview/course-overview.service.ts | 76 ++++++++++++++----- .../app/shared/sidebar/sidebar.component.ts | 3 +- src/main/webapp/app/types/sidebar.ts | 4 +- .../webapp/i18n/de/student-dashboard.json | 3 +- .../webapp/i18n/en/student-dashboard.json | 3 +- .../course/course-overview.service.spec.ts | 65 ++++++++++++++-- .../course-conversations.component.spec.ts | 46 +++++++++++ 9 files changed, 180 insertions(+), 47 deletions(-) diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html index 612880e9c6cd..691e877f45b7 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html @@ -34,7 +34,6 @@ (onBrowsePressed)="openChannelOverviewDialog()" (onDirectChatPressed)="openCreateOneToOneChatDialog()" (onGroupChatPressed)="openCreateGroupChatDialog()" - [showAddOption]="CHANNEL_TYPE_SHOW_ADD_OPTION" [channelTypeIcon]="CHANNEL_TYPE_ICON" [sidebarItemAlwaysShow]="DEFAULT_SHOW_ALWAYS" [collapseState]="DEFAULT_COLLAPSE_STATE" diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts index 94cfb4929066..17a73c85862d 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts @@ -13,6 +13,7 @@ import { PageType, SortDirection } from 'app/shared/metis/metis.util'; import { faBan, faBookmark, + faClock, faComment, faComments, faFile, @@ -27,7 +28,7 @@ import { } from '@fortawesome/free-solid-svg-icons'; import { ButtonType } from 'app/shared/components/button.component'; import { CourseWideSearchComponent, CourseWideSearchConfig } from 'app/overview/course-conversations/course-wide-search/course-wide-search.component'; -import { AccordionGroups, ChannelAccordionShowAdd, ChannelTypeIcons, CollapseState, SidebarCardElement, SidebarData, SidebarItemShowAlways } from 'app/types/sidebar'; +import { AccordionGroups, ChannelTypeIcons, CollapseState, SidebarCardElement, SidebarData, SidebarItemShowAlways } from 'app/types/sidebar'; import { CourseOverviewService } from 'app/overview/course-overview.service'; import { GroupChatCreateDialogComponent } from 'app/overview/course-conversations/dialogs/group-chat-create-dialog/group-chat-create-dialog.component'; import { defaultFirstLayerDialogOptions, defaultSecondLayerDialogOptions } from 'app/overview/course-conversations/other/conversation.util'; @@ -44,6 +45,7 @@ import { canCreateChannel } from 'app/shared/metis/conversations/conversation-pe const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { favoriteChannels: { entityData: [] }, + recents: { entityData: [] }, generalChannels: { entityData: [] }, exerciseChannels: { entityData: [] }, lectureChannels: { entityData: [] }, @@ -52,18 +54,6 @@ const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { savedPosts: { entityData: [] }, }; -const CHANNEL_TYPE_SHOW_ADD_OPTION: ChannelAccordionShowAdd = { - generalChannels: true, - exerciseChannels: true, - examChannels: true, - groupChats: true, - directMessages: true, - favoriteChannels: false, - lectureChannels: true, - hiddenChannels: false, - savedPosts: false, -}; - const CHANNEL_TYPE_ICON: ChannelTypeIcons = { generalChannels: faMessage, exerciseChannels: faList, @@ -74,6 +64,7 @@ const CHANNEL_TYPE_ICON: ChannelTypeIcons = { lectureChannels: faFile, hiddenChannels: faBan, savedPosts: faBookmark, + recents: faClock, }; const DEFAULT_COLLAPSE_STATE: CollapseState = { @@ -86,6 +77,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = { lectureChannels: true, hiddenChannels: true, savedPosts: true, + recents: true, }; const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { @@ -98,6 +90,7 @@ const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { lectureChannels: false, hiddenChannels: false, savedPosts: true, + recents: true, }; @Component({ @@ -135,7 +128,6 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { openThreadOnFocus = false; selectedSavedPostStatus: null | SavedPostStatus = null; - readonly CHANNEL_TYPE_SHOW_ADD_OPTION = CHANNEL_TYPE_SHOW_ADD_OPTION; readonly CHANNEL_TYPE_ICON = CHANNEL_TYPE_ICON; readonly DEFAULT_COLLAPSE_STATE = DEFAULT_COLLAPSE_STATE; protected readonly DEFAULT_SHOW_ALWAYS = DEFAULT_SHOW_ALWAYS; @@ -409,8 +401,10 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { prepareSidebarData() { this.metisConversationService.forceRefresh().subscribe({ complete: () => { - this.sidebarConversations = this.courseOverviewService.mapConversationsToSidebarCardElements(this.conversationsOfUser); - this.accordionConversationGroups = this.courseOverviewService.groupConversationsByChannelType(this.conversationsOfUser, this.messagingEnabled); + this.sidebarConversations = this.courseOverviewService.mapConversationsToSidebarCardElements(this.course!, this.conversationsOfUser); + this.accordionConversationGroups = this.courseOverviewService.groupConversationsByChannelType(this.course!, this.conversationsOfUser, this.messagingEnabled); + const currentConversations = this.sidebarConversations?.filter((item) => item.isCurrent) || []; + this.accordionConversationGroups.recents.entityData = currentConversations; this.updateSidebarData(); }, }); diff --git a/src/main/webapp/app/overview/course-overview.service.ts b/src/main/webapp/app/overview/course-overview.service.ts index 97b42ff6dc3f..6e13440242bb 100644 --- a/src/main/webapp/app/overview/course-overview.service.ts +++ b/src/main/webapp/app/overview/course-overview.service.ts @@ -20,6 +20,7 @@ import { isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model import { ConversationService } from 'app/shared/metis/conversations/conversation.service'; import { StudentExam } from 'app/entities/student-exam.model'; import { SavedPostStatusMap } from 'app/entities/metis/posting.model'; +import { Course } from 'app/entities/course.model'; const DEFAULT_UNIT_GROUPS: AccordionGroups = { future: { entityData: [] }, @@ -58,6 +59,7 @@ const GROUP_DECISION_MATRIX: Record { + const aIsFavorite = a.conversation?.isFavorite ? 1 : 0; + const bIsFavorite = b.conversation?.isFavorite ? 1 : 0; + return bIsFavorite - aIsFavorite; + }); + } return groupedConversationGroups; } @@ -273,8 +293,8 @@ export class CourseOverviewService { return exams.map((exam, index) => this.mapExamToSidebarCardElement(exam, studentExams?.[index])); } - mapConversationsToSidebarCardElements(conversations: ConversationDTO[]) { - return conversations.map((conversation) => this.mapConversationToSidebarCardElement(conversation)); + mapConversationsToSidebarCardElements(course: Course, conversations: ConversationDTO[]) { + return conversations.map((conversation) => this.mapConversationToSidebarCardElement(course, conversation)); } mapLectureToSidebarCardElement(lecture: Lecture): SidebarCardElement { @@ -349,7 +369,28 @@ export class CourseOverviewService { } } - mapConversationToSidebarCardElement(conversation: ConversationDTO): SidebarCardElement { + mapConversationToSidebarCardElement(course: Course, conversation: ConversationDTO): SidebarCardElement { + let isCurrent = false; + const channelDTO = getAsChannelDTO(conversation); + const subTypeRefId = channelDTO?.subTypeReferenceId; + const now = dayjs(); + const oneAndHalfWeekBefore = now.subtract(1.5, 'week'); + const oneAndHalfWeekLater = now.add(1.5, 'week'); + let relevantDate = null; + if (subTypeRefId && course.exercises && channelDTO?.subType === 'exercise') { + const exercise = course.exercises.find((exercise) => exercise.id === subTypeRefId); + const relevantDates = [exercise?.releaseDate, exercise?.dueDate].filter(Boolean); + isCurrent = relevantDates.some((date) => dayjs(date).isBetween(oneAndHalfWeekBefore, oneAndHalfWeekLater, 'day', '[]')); + } else if (subTypeRefId && course.lectures && channelDTO?.subType === 'lecture') { + const lecture = course.lectures.find((lecture) => lecture.id === subTypeRefId); + relevantDate = lecture?.startDate || null; + isCurrent = relevantDate ? dayjs(relevantDate).isBetween(oneAndHalfWeekBefore, oneAndHalfWeekLater, 'day', '[]') : false; + } else if (subTypeRefId && course.exams && channelDTO?.subType === 'exam') { + const exam = course.exams.find((exam) => exam.id === subTypeRefId); + relevantDate = exam?.startDate || null; + isCurrent = relevantDate ? dayjs(relevantDate).isBetween(oneAndHalfWeekBefore, oneAndHalfWeekLater, 'day', '[]') : false; + } + const conversationCardItem: SidebarCardElement = { title: this.conversationService.getConversationName(conversation) ?? '', id: conversation.id ?? '', @@ -357,6 +398,7 @@ export class CourseOverviewService { icon: this.getChannelIcon(conversation), conversation: conversation, size: 'S', + isCurrent: isCurrent, }; return conversationCardItem; } diff --git a/src/main/webapp/app/shared/sidebar/sidebar.component.ts b/src/main/webapp/app/shared/sidebar/sidebar.component.ts index 839f1583f930..4422958d48cd 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar.component.ts +++ b/src/main/webapp/app/shared/sidebar/sidebar.component.ts @@ -3,7 +3,7 @@ import { faCheckDouble, faFilter, faFilterCircleXmark, faHashtag, faPeopleGroup, import { ActivatedRoute, Params } from '@angular/router'; import { Subscription, distinctUntilChanged } from 'rxjs'; import { ProfileService } from '../layouts/profiles/profile.service'; -import { ChannelAccordionShowAdd, ChannelTypeIcons, CollapseState, SidebarCardSize, SidebarData, SidebarItemShowAlways, SidebarTypes } from 'app/types/sidebar'; +import { ChannelTypeIcons, CollapseState, SidebarCardSize, SidebarData, SidebarItemShowAlways, SidebarTypes } from 'app/types/sidebar'; import { SidebarEventService } from './sidebar-event.service'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { cloneDeep } from 'lodash-es'; @@ -33,7 +33,6 @@ export class SidebarComponent implements OnDestroy, OnChanges, OnInit { @Input() sidebarData: SidebarData; @Input() courseId?: number; @Input() itemSelected?: boolean; - @Input() showAddOption?: ChannelAccordionShowAdd; @Input() channelTypeIcon?: ChannelTypeIcons; @Input() collapseState: CollapseState; sidebarItemAlwaysShow = input.required(); diff --git a/src/main/webapp/app/types/sidebar.ts b/src/main/webapp/app/types/sidebar.ts index 48180c03ebfb..7143b45b9298 100644 --- a/src/main/webapp/app/types/sidebar.ts +++ b/src/main/webapp/app/types/sidebar.ts @@ -16,6 +16,7 @@ export type AccordionGroups = Record< >; export type ChannelGroupCategory = | 'favoriteChannels' + | 'recents' | 'generalChannels' | 'exerciseChannels' | 'lectureChannels' @@ -27,7 +28,6 @@ export type ChannelGroupCategory = export type CollapseState = { [key: string]: boolean; } & (Record | Record | Record | Record); -export type ChannelAccordionShowAdd = Record; export type ChannelTypeIcons = Record; export type SidebarItemShowAlways = { [key: string]: boolean; @@ -135,4 +135,6 @@ export interface SidebarCardElement { * Set for Conversation. Will be removed after refactoring */ conversation?: ConversationDTO; + + isCurrent?: boolean; } diff --git a/src/main/webapp/i18n/de/student-dashboard.json b/src/main/webapp/i18n/de/student-dashboard.json index 1efa807cf985..a605350eabb0 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -83,7 +83,8 @@ "groupChats": "Gruppenchats", "directMessages": "Direktnachrichten", "filterConversationPlaceholder": "Konversationen filtern", - "setChannelAsRead": "Alle Kanäle als gelesen markieren" + "setChannelAsRead": "Alle Kanäle als gelesen markieren", + "recents": "Kürzliches" }, "menu": { "exercises": "Aufgaben", diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index a1be35bda0a4..0488b491b4ac 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -83,7 +83,8 @@ "groupChats": "Group Chats", "directMessages": "Direct Messages", "filterConversationPlaceholder": "Filter conversations", - "setChannelAsRead": "Mark all channels as read" + "setChannelAsRead": "Mark all channels as read", + "recents": "Recents" }, "menu": { "exercises": "Exercises", diff --git a/src/test/javascript/spec/component/course/course-overview.service.spec.ts b/src/test/javascript/spec/component/course/course-overview.service.spec.ts index 728d0fb269a8..26b6028a6bf6 100644 --- a/src/test/javascript/spec/component/course/course-overview.service.spec.ts +++ b/src/test/javascript/spec/component/course/course-overview.service.spec.ts @@ -4,7 +4,6 @@ import { ModelingExercise } from 'app/entities/modeling-exercise.model'; import { Exercise } from 'app/entities/exercise.model'; import { UMLDiagramType } from '@ls1intum/apollon'; import { Course } from 'app/entities/course.model'; -import dayjs from 'dayjs/esm'; import { Lecture } from 'app/entities/lecture.model'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { TranslateService } from '@ngx-translate/core'; @@ -15,6 +14,8 @@ import { TextExercise } from 'app/entities/text/text-exercise.model'; import { Exam } from 'app/entities/exam/exam.model'; import { ChannelDTO, ChannelSubType, getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { provideHttpClient } from '@angular/common/http'; +import dayjs from 'dayjs/esm'; +import { ConversationDTO, ConversationType } from 'app/entities/metis/conversation/conversation.model'; describe('CourseOverviewService', () => { let service: CourseOverviewService; @@ -429,7 +430,7 @@ describe('CourseOverviewService', () => { jest.spyOn(service, 'getCorrespondingChannelSubType'); jest.spyOn(service, 'mapConversationToSidebarCardElement'); - const groupedConversations = service.groupConversationsByChannelType(conversations, true); + const groupedConversations = service.groupConversationsByChannelType(course, conversations, true); expect(groupedConversations['generalChannels'].entityData).toHaveLength(1); expect(groupedConversations['examChannels'].entityData).toHaveLength(1); @@ -445,7 +446,7 @@ describe('CourseOverviewService', () => { jest.spyOn(service, 'getCorrespondingChannelSubType'); jest.spyOn(service, 'mapConversationToSidebarCardElement'); - const groupedConversations = service.groupConversationsByChannelType(conversations, true); + const groupedConversations = service.groupConversationsByChannelType(course, conversations, true); expect(groupedConversations['generalChannels'].entityData).toHaveLength(2); expect(service.mapConversationToSidebarCardElement).toHaveBeenCalledTimes(2); @@ -460,21 +461,69 @@ describe('CourseOverviewService', () => { jest.spyOn(service, 'mapConversationToSidebarCardElement'); jest.spyOn(service, 'getConversationGroup'); jest.spyOn(service, 'getCorrespondingChannelSubType'); - const groupedConversations = service.groupConversationsByChannelType(conversations, true); + const groupedConversations = service.groupConversationsByChannelType(course, conversations, true); - expect(groupedConversations['generalChannels'].entityData).toHaveLength(2); + expect(groupedConversations['generalChannels'].entityData).toHaveLength(3); expect(groupedConversations['examChannels'].entityData).toHaveLength(1); expect(groupedConversations['exerciseChannels'].entityData).toHaveLength(1); expect(groupedConversations['favoriteChannels'].entityData).toHaveLength(1); expect(groupedConversations['hiddenChannels'].entityData).toHaveLength(1); expect(service.mapConversationToSidebarCardElement).toHaveBeenCalledTimes(6); expect(service.getConversationGroup).toHaveBeenCalledTimes(6); - expect(service.getCorrespondingChannelSubType).toHaveBeenCalledTimes(4); - expect(getAsChannelDTO(groupedConversations['generalChannels'].entityData[0].conversation)?.name).toBe('General'); - expect(getAsChannelDTO(groupedConversations['generalChannels'].entityData[1].conversation)?.name).toBe('General 2'); + expect(service.getCorrespondingChannelSubType).toHaveBeenCalledTimes(5); + expect(getAsChannelDTO(groupedConversations['generalChannels'].entityData[0].conversation)?.name).toBe('fav-channel'); + expect(getAsChannelDTO(groupedConversations['generalChannels'].entityData[1].conversation)?.name).toBe('General'); + expect(getAsChannelDTO(groupedConversations['generalChannels'].entityData[2].conversation)?.name).toBe('General 2'); expect(getAsChannelDTO(groupedConversations['examChannels'].entityData[0].conversation)?.name).toBe('exam-test'); expect(getAsChannelDTO(groupedConversations['exerciseChannels'].entityData[0].conversation)?.name).toBe('exercise-test'); expect(getAsChannelDTO(groupedConversations['favoriteChannels'].entityData[0].conversation)?.name).toBe('fav-channel'); expect(getAsChannelDTO(groupedConversations['hiddenChannels'].entityData[0].conversation)?.name).toBe('hidden-channel'); }); + + it('should not remove favorite conversations from their original section but keep them at the top of the related section', () => { + const conversations = [generalChannel, examChannel, exerciseChannel, favoriteChannel]; + + jest.spyOn(service, 'getCorrespondingChannelSubType'); + jest.spyOn(service, 'mapConversationToSidebarCardElement'); + jest.spyOn(service, 'getConversationGroup'); + const groupedConversations = service.groupConversationsByChannelType(course, conversations, true); + + expect(groupedConversations['favoriteChannels'].entityData).toContainEqual(expect.objectContaining({ id: favoriteChannel.id })); + + expect(groupedConversations['generalChannels'].entityData[0].id).toBe(favoriteChannel.id); + + expect(service.mapConversationToSidebarCardElement).toHaveBeenCalledTimes(4); + expect(service.getConversationGroup).toHaveBeenCalledTimes(4); + expect(service.getCorrespondingChannelSubType).toHaveBeenCalledTimes(4); + }); + + it('should correctly set isCurrent based on the date range in mapConversationToSidebarCardElement', () => { + const now = dayjs(); + const oneAndHalfWeekBefore = now.subtract(1.5, 'week'); + + const conversationWithinRange = { + id: 5, + subType: ChannelSubType.EXERCISE, + subTypeReferenceId: 101, + type: ConversationType.CHANNEL, + } as ConversationDTO; + + const conversationOutsideRange = { + subType: ChannelSubType.LECTURE, + subTypeReferenceId: 102, + type: ConversationType.CHANNEL, + } as ConversationDTO; + + const exerciseWithinRange = { id: 101, dueDate: oneAndHalfWeekBefore.add(3, 'day') } as unknown as Exercise; + const lectureOutsideRange = { id: 102, startDate: oneAndHalfWeekBefore.subtract(1, 'day') } as unknown as Lecture; + + course.exercises = [exerciseWithinRange]; + course.lectures = [lectureOutsideRange]; + + const sidebarCardWithinRange = service.mapConversationToSidebarCardElement(course, conversationWithinRange); + const sidebarCardOutsideRange = service.mapConversationToSidebarCardElement(course, conversationOutsideRange); + + expect(sidebarCardWithinRange.isCurrent).toBeTrue(); + expect(sidebarCardOutsideRange.isCurrent).toBeFalse(); + }); }); diff --git a/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts index a702982bdd36..e2f7139bac47 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts @@ -63,6 +63,7 @@ examples.forEach((activeConversation) => { let acceptCodeOfConductSpy: jest.SpyInstance; let setActiveConversationSpy: jest.SpyInstance; let metisConversationService: MetisConversationService; + let courseOverviewService: CourseOverviewService; let modalService: NgbModal; let courseSidebarService: CourseSidebarService; let layoutService: LayoutService; @@ -136,6 +137,7 @@ examples.forEach((activeConversation) => { }); metisConversationService = TestBed.inject(MetisConversationService); + courseOverviewService = TestBed.inject(CourseOverviewService); courseSidebarService = TestBed.inject(CourseSidebarService); layoutService = TestBed.inject(LayoutService); activatedRoute = TestBed.inject(ActivatedRoute); @@ -164,6 +166,39 @@ examples.forEach((activeConversation) => { acceptCodeOfConductSpy = jest.spyOn(metisConversationService, 'acceptCodeOfConduct'); jest.spyOn(metisService, 'posts', 'get').mockReturnValue(postsSubject.asObservable()); modalService = TestBed.inject(NgbModal); + component.sidebarConversations = []; + + jest.spyOn(courseOverviewService, 'mapConversationsToSidebarCardElements').mockReturnValue([ + { + id: 1, + title: 'Test Channel 1', + isCurrent: true, + conversation: { id: 1 }, + size: 'S', + }, + { + id: 2, + title: 'Test Channel 2', + isCurrent: false, + conversation: { id: 2 }, + size: 'S', + }, + ]); + + jest.spyOn(courseOverviewService, 'groupConversationsByChannelType').mockReturnValue({ + recents: { + entityData: [ + { + id: 1, + title: 'Test Channel 1', + isCurrent: true, + conversation: { id: 1 }, + size: 'S', + }, + ], + }, + generalChannels: { entityData: [] }, + }); })); afterEach(() => { @@ -439,6 +474,17 @@ examples.forEach((activeConversation) => { // Since createChannelFn is undefined, prepareSidebarData should not be called expect(prepareSidebarDataSpy).not.toHaveBeenCalled(); }); + + it('should correctly populate the recents group in accordionConversationGroups using existing mocks', fakeAsync(() => { + (metisConversationService.forceRefresh as jest.Mock).mockReturnValue(of({})); + + component.prepareSidebarData(); + tick(); + const recentsGroup = component.accordionConversationGroups.recents; + expect(recentsGroup).toBeDefined(); + expect(recentsGroup.entityData).toHaveLength(1); + expect(recentsGroup.entityData[0].isCurrent).toBeTrue(); + })); }); describe('query parameter handling', () => {