diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Faq.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Faq.java new file mode 100644 index 000000000000..6a2991c9a6fb --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Faq.java @@ -0,0 +1,99 @@ +package de.tum.cit.aet.artemis.communication.domain; + +import java.util.HashSet; +import java.util.Set; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.AbstractAuditingEntity; +import de.tum.cit.aet.artemis.core.domain.Course; + +/** + * A FAQ. + */ +@Entity +@Table(name = "faq") +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class Faq extends AbstractAuditingEntity { + + @Column(name = "question_title") + private String questionTitle; + + @Column(name = "question_answer") + private String questionAnswer; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "faq_categories", joinColumns = @JoinColumn(name = "faq_id")) + @Column(name = "categories") + private Set categories = new HashSet<>(); + + @Enumerated(EnumType.STRING) + @Column(name = "faq_state") + private FaqState faqState; + + @ManyToOne + @JsonIgnoreProperties(value = { "faqs" }, allowSetters = true) + private Course course; + + public String getQuestionTitle() { + return questionTitle; + } + + public void setQuestionTitle(String questionTitle) { + this.questionTitle = questionTitle; + } + + public String getQuestionAnswer() { + return questionAnswer; + } + + public void setQuestionAnswer(String questionAnswer) { + this.questionAnswer = questionAnswer; + } + + public Course getCourse() { + return course; + } + + public void setCourse(Course course) { + this.course = course; + } + + public Set getCategories() { + return categories; + } + + public void setCategories(Set categories) { + this.categories = categories; + } + + public FaqState getFaqState() { + return faqState; + } + + public void setFaqState(FaqState faqState) { + this.faqState = faqState; + } + + @Override + public String toString() { + return "Faq{" + "id=" + getId() + ", title='" + getQuestionTitle() + "'" + ", description='" + getQuestionTitle() + "'" + ", faqState='" + getFaqState() + "}"; + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/FaqState.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/FaqState.java new file mode 100644 index 000000000000..9018a3be3a12 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/FaqState.java @@ -0,0 +1,5 @@ +package de.tum.cit.aet.artemis.communication.domain; + +public enum FaqState { + ACCEPTED, REJECTED, PROPOSED +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java new file mode 100644 index 000000000000..d1584d66fcd3 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java @@ -0,0 +1,47 @@ +package de.tum.cit.aet.artemis.communication.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.Set; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +/** + * Spring Data repository for the Faq entity. + */ +@Profile(PROFILE_CORE) +@Repository +public interface FaqRepository extends ArtemisJpaRepository { + + @Query(""" + SELECT faq + FROM Faq faq + WHERE faq.course.id = :courseId + """) + Set findAllByCourseId(@Param("courseId") Long courseId); + + @Query(""" + SELECT DISTINCT faq.categories + FROM Faq faq + WHERE faq.course.id = :courseId + """) + Set findAllCategoriesByCourseId(@Param("courseId") Long courseId); + + @Transactional + @Modifying + @Query(""" + DELETE + FROM Faq faq + WHERE faq.course.id = :courseId + """) + void deleteAllByCourseId(@Param("courseId") Long courseId); + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java new file mode 100644 index 000000000000..9c02a78078d1 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java @@ -0,0 +1,179 @@ +package de.tum.cit.aet.artemis.communication.web; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.repository.FaqRepository; +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; +import de.tum.cit.aet.artemis.core.repository.CourseRepository; +import de.tum.cit.aet.artemis.core.security.Role; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; +import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.core.util.HeaderUtil; + +/** + * REST controller for managing Faqs. + */ +@Profile(PROFILE_CORE) +@RestController +@RequestMapping("api/") +public class FaqResource { + + private static final Logger log = LoggerFactory.getLogger(FaqResource.class); + + private static final String ENTITY_NAME = "faq"; + + @Value("${jhipster.clientApp.name}") + private String applicationName; + + private final CourseRepository courseRepository; + + private final FaqRepository faqRepository; + + private final AuthorizationCheckService authCheckService; + + public FaqResource(CourseRepository courseRepository, AuthorizationCheckService authCheckService, FaqRepository faqRepository) { + + this.courseRepository = courseRepository; + this.authCheckService = authCheckService; + this.faqRepository = faqRepository; + } + + /** + * POST /faqs : Create a new faq. + * + * @param faq the faq to create + * @return the ResponseEntity with status 201 (Created) and with body the new faq, or with status 400 (Bad Request) if the faq has already an ID + * @throws URISyntaxException if the Location URI syntax is incorrect + */ + @PostMapping("faqs") + @EnforceAtLeastInstructor + public ResponseEntity createFaq(@RequestBody Faq faq) throws URISyntaxException { + log.debug("REST request to save Faq : {}", faq); + if (faq.getId() != null) { + throw new BadRequestAlertException("A new faq cannot already have an ID", ENTITY_NAME, "idExists"); + } + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, faq.getCourse(), null); + + Faq savedFaq = faqRepository.save(faq); + return ResponseEntity.created(new URI("/api/faqs/" + savedFaq.getId())).body(savedFaq); + } + + /** + * PUT /faqs/{faqId} : Updates an existing faq. + * + * @param faq the faq to update + * @param faqId id of the faq to be updated + * @return the ResponseEntity with status 200 (OK) and with body the updated faq, or with status 400 (Bad Request) if the faq is not valid, or with status 500 (Internal + * Server Error) if the faq couldn't be updated + */ + @PutMapping("faqs/{faqId}") + @EnforceAtLeastInstructor + public ResponseEntity updateFaq(@RequestBody Faq faq, @PathVariable Long faqId) { + log.debug("REST request to update Faq : {}", faq); + if (faqId == null || !faqId.equals(faq.getId())) { + throw new BadRequestAlertException("Id of FAQ and path must match", ENTITY_NAME, "idNull"); + } + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, faq.getCourse(), null); + Faq existingFaq = faqRepository.findByIdElseThrow(faqId); + Faq result = faqRepository.save(faq); + return ResponseEntity.ok().body(result); + } + + /** + * GET /faqs/:faqId : get the "faqId" faq. + * + * @param faqId the faqId of the faq to retrieve + * @return the ResponseEntity with status 200 (OK) and with body the faq, or with status 404 (Not Found) + */ + @GetMapping("faqs/{faqId}") + @EnforceAtLeastStudent + public ResponseEntity getFaq(@PathVariable Long faqId) { + log.debug("REST request to get faq {}", faqId); + Faq faq = faqRepository.findByIdElseThrow(faqId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, faq.getCourse(), null); + return ResponseEntity.ok(faq); + } + + /** + * DELETE /faqs/:faqId : delete the "id" faq. + * + * @param faqId the id of the faq to delete + * @return the ResponseEntity with status 200 (OK) + */ + @DeleteMapping("faqs/{faqId}") + @EnforceAtLeastInstructor + public ResponseEntity deleteFaq(@PathVariable Long faqId) { + + log.debug("REST request to delete faq {}", faqId); + Faq faq = faqRepository.findByIdElseThrow(faqId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, faq.getCourse(), null); + faqRepository.deleteById(faqId); + return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, faqId.toString())).build(); + } + + /** + * GET /courses/:courseId/faqs : get all the faqs of a course + * + * @param courseId the courseId of the course for which all faqs should be returned + * @return the ResponseEntity with status 200 (OK) and the list of faqs in body + */ + @GetMapping("courses/{courseId}/faqs") + @EnforceAtLeastStudent + public ResponseEntity> getFaqForCourse(@PathVariable Long courseId) { + log.debug("REST request to get all Faqs for the course with id : {}", courseId); + + Course course = getCourseForRequest(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, null); + Set faqs = faqRepository.findAllByCourseId(courseId); + return ResponseEntity.ok().body(faqs); + } + + /** + * GET /courses/:courseId/faq-categories : get all the faq categories of a course + * + * @param courseId the courseId of the course for which all faq categories should be returned + * @return the ResponseEntity with status 200 (OK) and the list of faqs in body + */ + @GetMapping("courses/{courseId}/faq-categories") + @EnforceAtLeastStudent + public ResponseEntity> getFaqCategoriesForCourse(@PathVariable Long courseId) { + log.debug("REST request to get all Faq Categories for the course with id : {}", courseId); + + Course course = getCourseForRequest(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, null); + Set faqs = faqRepository.findAllCategoriesByCourseId(courseId); + + return ResponseEntity.ok().body(faqs); + } + + /** + * + * @param courseId the courseId of the course + * @return the course with the id courseId, unless it exists + */ + private Course getCourseForRequest(Long courseId) { + return courseRepository.findByIdElseThrow(courseId); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/Course.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/Course.java index beacc4af0aa0..a8cb50eb4d83 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/domain/Course.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/Course.java @@ -37,6 +37,7 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.Competency; import de.tum.cit.aet.artemis.atlas.domain.competency.LearningPath; import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite; +import de.tum.cit.aet.artemis.communication.domain.Faq; import de.tum.cit.aet.artemis.communication.domain.Post; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.exam.domain.Exam; @@ -187,6 +188,9 @@ public class Course extends DomainObject { @Column(name = "unenrollment_enabled") private boolean unenrollmentEnabled = false; + @Column(name = "faq_enabled") + private Boolean faqEnabled = false; + @Column(name = "presentation_score") private Integer presentationScore; @@ -260,6 +264,10 @@ public class Course extends DomainObject { @JsonIgnoreProperties("course") private TutorialGroupsConfiguration tutorialGroupsConfiguration; + @OneToMany(mappedBy = "course", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + @JsonIgnoreProperties(value = "course", allowSetters = true) + private Set faqs = new HashSet<>(); + // NOTE: Helpers variable names must be different from Getter name, so that Jackson ignores the @Transient annotation, but Hibernate still respects it @Transient private Long numberOfInstructorsTransient; @@ -627,6 +635,14 @@ public void setEnrollmentEnabled(Boolean enrollmentEnabled) { this.enrollmentEnabled = enrollmentEnabled; } + public Boolean isFaqEnabled() { + return faqEnabled; + } + + public void setFaqEnabled(Boolean faqEnabled) { + this.faqEnabled = faqEnabled; + } + public String getEnrollmentConfirmationMessage() { return enrollmentConfirmationMessage; } @@ -717,7 +733,7 @@ public String toString() { + "'" + ", enrollmentStartDate='" + getEnrollmentStartDate() + "'" + ", enrollmentEndDate='" + getEnrollmentEndDate() + "'" + ", unenrollmentEndDate='" + getUnenrollmentEndDate() + "'" + ", semester='" + getSemester() + "'" + "'" + ", onlineCourse='" + isOnlineCourse() + "'" + ", color='" + getColor() + "'" + ", courseIcon='" + getCourseIcon() + "'" + ", enrollmentEnabled='" + isEnrollmentEnabled() + "'" + ", unenrollmentEnabled='" + isUnenrollmentEnabled() + "'" - + ", presentationScore='" + getPresentationScore() + "'" + "}"; + + ", presentationScore='" + getPresentationScore() + "'" + ", faqEnabled='" + isFaqEnabled() + "'" + "}"; } public void setNumberOfInstructors(Long numberOfInstructors) { @@ -1057,4 +1073,17 @@ public String getMappedColumnName() { return mappedColumnName; } } + + public Set getFaqs() { + return faqs; + } + + public void setFaqs(Set faqs) { + this.faqs = faqs; + } + + public void addFaq(Faq faq) { + this.faqs.add(faq); + faq.setCourse(this); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java index 9e3b69f269cc..a57b8880d96b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java @@ -59,6 +59,7 @@ import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; import de.tum.cit.aet.artemis.communication.domain.NotificationType; import de.tum.cit.aet.artemis.communication.domain.notification.GroupNotification; +import de.tum.cit.aet.artemis.communication.repository.FaqRepository; import de.tum.cit.aet.artemis.communication.repository.GroupNotificationRepository; import de.tum.cit.aet.artemis.communication.repository.conversation.ConversationRepository; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationService; @@ -117,6 +118,8 @@ public class CourseService { private static final Logger log = LoggerFactory.getLogger(CourseService.class); + private final FaqRepository faqRepository; + @Value("${artemis.course-archives-path}") private Path courseArchivesDirPath; @@ -210,7 +213,7 @@ public CourseService(CourseRepository courseRepository, ExerciseService exercise TutorialGroupRepository tutorialGroupRepository, PlagiarismCaseRepository plagiarismCaseRepository, ConversationRepository conversationRepository, LearningPathService learningPathService, Optional irisSettingsService, LectureRepository lectureRepository, TutorialGroupNotificationRepository tutorialGroupNotificationRepository, TutorialGroupChannelManagementService tutorialGroupChannelManagementService, - PrerequisiteRepository prerequisiteRepository, CompetencyRelationRepository competencyRelationRepository) { + PrerequisiteRepository prerequisiteRepository, CompetencyRelationRepository competencyRelationRepository, FaqRepository faqRepository) { this.courseRepository = courseRepository; this.exerciseService = exerciseService; this.exerciseDeletionService = exerciseDeletionService; @@ -250,6 +253,7 @@ public CourseService(CourseRepository courseRepository, ExerciseService exercise this.tutorialGroupChannelManagementService = tutorialGroupChannelManagementService; this.prerequisiteRepository = prerequisiteRepository; this.competencyRelationRepository = competencyRelationRepository; + this.faqRepository = faqRepository; } /** @@ -467,6 +471,7 @@ public void delete(Course course) { deleteDefaultGroups(course); deleteExamsOfCourse(course); deleteGradingScaleOfCourse(course); + deleteFaqOfCourse(course); irisSettingsService.ifPresent(iss -> iss.deleteSettingsFor(course)); courseRepository.deleteById(course.getId()); log.debug("Successfully deleted course {}.", course.getTitle()); @@ -542,6 +547,10 @@ private void deleteCompetenciesOfCourse(Course course) { competencyRepository.deleteAll(course.getCompetencies()); } + private void deleteFaqOfCourse(Course course) { + faqRepository.deleteAllByCourseId(course.getId()); + } + /** * If the exercise is part of an exam, retrieve the course through ExerciseGroup -> Exam -> Course. * Otherwise, the course is already set and the id can be used to retrieve the course from the database. diff --git a/src/main/resources/config/liquibase/changelog/20240902175045_changelog.xml b/src/main/resources/config/liquibase/changelog/20240902175045_changelog.xml new file mode 100644 index 000000000000..3f2400598da3 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20240902175045_changelog.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 2c204094c0ff..7921118cdfa3 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -23,6 +23,7 @@ + diff --git a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html index 3c953bcf4e3a..abbfdaf7c010 100644 --- a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html +++ b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html @@ -72,6 +72,12 @@ } + @if (course.isAtLeastInstructor && course.faqEnabled) { + + + + + } @if (course.isAtLeastInstructor && localCIActive) { diff --git a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts index c46c04bbcf5d..c25c182067e3 100644 --- a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts +++ b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts @@ -20,6 +20,7 @@ import { faNetworkWired, faPersonChalkboard, faPuzzlePiece, + faQuestion, faRobot, faTable, faTrash, @@ -73,6 +74,7 @@ export class CourseManagementTabBarComponent implements OnInit, OnDestroy, After faRobot = faRobot; faPuzzlePiece = faPuzzlePiece; faList = faList; + faQuestion = faQuestion; isCommunicationEnabled = false; diff --git a/src/main/webapp/app/course/manage/course-management.module.ts b/src/main/webapp/app/course/manage/course-management.module.ts index 333de23de8b3..22cfc65562a8 100644 --- a/src/main/webapp/app/course/manage/course-management.module.ts +++ b/src/main/webapp/app/course/manage/course-management.module.ts @@ -70,6 +70,7 @@ import { SubmissionResultStatusModule } from 'app/overview/submission-result-sta import { ImageCropperModalComponent } from 'app/course/manage/image-cropper-modal.component'; import { HeaderCourseComponent } from 'app/overview/header-course.component'; import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; +import { ArtemisFAQModule } from 'app/faq/faq.module'; @NgModule({ imports: [ @@ -124,6 +125,7 @@ import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown DetailModule, SubmissionResultStatusModule, ArtemisMarkdownEditorModule, + ArtemisFAQModule, ], declarations: [ CourseManagementComponent, diff --git a/src/main/webapp/app/course/manage/course-update.component.html b/src/main/webapp/app/course/manage/course-update.component.html index 43b03f3f9040..e48fd621970f 100644 --- a/src/main/webapp/app/course/manage/course-update.component.html +++ b/src/main/webapp/app/course/manage/course-update.component.html @@ -309,6 +309,15 @@
} diff --git a/src/main/webapp/app/course/manage/overview/course-management-card.component.ts b/src/main/webapp/app/course/manage/overview/course-management-card.component.ts index 32dca0a3fc2d..2fd25af861ed 100644 --- a/src/main/webapp/app/course/manage/overview/course-management-card.component.ts +++ b/src/main/webapp/app/course/manage/overview/course-management-card.component.ts @@ -19,6 +19,7 @@ import { faListAlt, faNetworkWired, faPersonChalkboard, + faQuestion, faSpinner, faTable, faUserCheck, @@ -77,6 +78,7 @@ export class CourseManagementCardComponent implements OnChanges { faAngleUp = faAngleUp; faPersonChalkboard = faPersonChalkboard; faSpinner = faSpinner; + faQuestion = faQuestion; courseColor: string; diff --git a/src/main/webapp/app/entities/course.model.ts b/src/main/webapp/app/entities/course.model.ts index 4f179de3a687..6cddcfe61040 100644 --- a/src/main/webapp/app/entities/course.model.ts +++ b/src/main/webapp/app/entities/course.model.ts @@ -62,6 +62,7 @@ export class Course implements BaseEntity { public color?: string; public courseIcon?: string; public onlineCourse?: boolean; + public faqEnabled?: boolean; public enrollmentEnabled?: boolean; public enrollmentConfirmationMessage?: string; public unenrollmentEnabled?: boolean; diff --git a/src/main/webapp/app/entities/faq-category.model.ts b/src/main/webapp/app/entities/faq-category.model.ts new file mode 100644 index 000000000000..b7ef47b24d13 --- /dev/null +++ b/src/main/webapp/app/entities/faq-category.model.ts @@ -0,0 +1,29 @@ +export class FAQCategory { + public color?: string; + + public category?: string; + + constructor(category: string | undefined, color: string | undefined) { + this.color = color; + this.category = category; + } + + equals(otherExerciseCategory: FAQCategory): boolean { + return this.color === otherExerciseCategory.color && this.category === otherExerciseCategory.category; + } + + /** + * @param otherExerciseCategory + * @returns the alphanumerical order of the two exercise categories based on their display text + */ + compare(otherExerciseCategory: FAQCategory): number { + if (this.category === otherExerciseCategory.category) { + return 0; + } + + const displayText = this.category?.toLowerCase() ?? ''; + const otherCategoryDisplayText = otherExerciseCategory.category?.toLowerCase() ?? ''; + + return displayText < otherCategoryDisplayText ? -1 : 1; + } +} diff --git a/src/main/webapp/app/entities/faq.model.ts b/src/main/webapp/app/entities/faq.model.ts new file mode 100644 index 000000000000..35736ba9296d --- /dev/null +++ b/src/main/webapp/app/entities/faq.model.ts @@ -0,0 +1,18 @@ +import { BaseEntity } from 'app/shared/model/base-entity'; +import { Course } from 'app/entities/course.model'; +import { FAQCategory } from './faq-category.model'; + +export enum FAQState { + ACCEPTED, + REJECTED, + PROPOSED, +} + +export class FAQ implements BaseEntity { + public id?: number; + public questionTitle?: string; + public questionAnswer?: string; + public faqState?: FAQState; + public course?: Course; + public categories?: FAQCategory[]; +} diff --git a/src/main/webapp/app/faq/faq-update.component.html b/src/main/webapp/app/faq/faq-update.component.html new file mode 100644 index 000000000000..f53c037fe670 --- /dev/null +++ b/src/main/webapp/app/faq/faq-update.component.html @@ -0,0 +1,47 @@ +
+
+
+
+
+
+

+
+
+
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+ @if (faq.course) { +
+ +
+ +
+
+ } +
+
+ + +
+
+
+
+
+
diff --git a/src/main/webapp/app/faq/faq-update.component.scss b/src/main/webapp/app/faq/faq-update.component.scss new file mode 100644 index 000000000000..c8c63e8a710c --- /dev/null +++ b/src/main/webapp/app/faq/faq-update.component.scss @@ -0,0 +1,3 @@ +.markdown-editor { + height: 350px; +} diff --git a/src/main/webapp/app/faq/faq-update.component.ts b/src/main/webapp/app/faq/faq-update.component.ts new file mode 100644 index 000000000000..9779268904fb --- /dev/null +++ b/src/main/webapp/app/faq/faq-update.component.ts @@ -0,0 +1,153 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { ArtemisNavigationUtilService } from 'app/utils/navigation.utils'; +import { faBan, faQuestionCircle, faSave } from '@fortawesome/free-solid-svg-icons'; +import { FormulaAction } from 'app/shared/monaco-editor/model/actions/formula.action'; +import { FAQ, FAQState } from 'app/entities/faq.model'; +import { FAQService } from 'app/faq/faq.service'; +import { TranslateService } from '@ngx-translate/core'; +import { FAQCategory } from 'app/entities/faq-category.model'; +import { loadCourseFaqCategories } from 'app/faq/faq.utils'; +import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; +import { ArtemisCategorySelectorModule } from 'app/shared/category-selector/category-selector.module'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; + +@Component({ + selector: 'jhi-faq-update', + templateUrl: './faq-update.component.html', + styleUrls: ['./faq-update.component.scss'], + standalone: true, + imports: [ArtemisSharedModule, ArtemisSharedComponentModule, ArtemisMarkdownEditorModule, ArtemisCategorySelectorModule], +}) +export class FAQUpdateComponent implements OnInit { + faq: FAQ; + isSaving: boolean; + existingCategories: FAQCategory[]; + faqCategories: FAQCategory[]; + + domainActionsDescription = [new FormulaAction()]; + + // Icons + faQuestionCircle = faQuestionCircle; + faSave = faSave; + faBan = faBan; + + constructor( + protected alertService: AlertService, + protected faqService: FAQService, + protected activatedRoute: ActivatedRoute, + private navigationUtilService: ArtemisNavigationUtilService, + private router: Router, + private translateService: TranslateService, + ) {} + + /** + * Life cycle hook called by Angular to indicate that Angular is done creating the component + */ + ngOnInit() { + this.isSaving = false; + this.activatedRoute.parent?.data.subscribe((data) => { + // Create a new faq to use unless we fetch an existing faq + const faq = data['faq']; + this.faq = faq ?? new FAQ(); + const course = data['course']; + if (course) { + this.faq.course = course; + this.loadCourseFaqCategories(course.id); + } + this.faqCategories = faq?.categories ? faq.categories : []; + }); + } + + /** + * Revert to the previous state, equivalent with pressing the back button on your browser + * Returns to the detail page if there is no previous state and we edited an existing faq + * Returns to the overview page if there is no previous state and we created a new faq + */ + + previousState() { + this.navigationUtilService.navigateBack(['course-management', this.faq.course!.id!.toString(), 'faqs']); + } + /** + * Save the changes on a faq + * This function is called by pressing save after creating or editing a faq + */ + save() { + this.isSaving = true; + if (this.faq.id !== undefined) { + this.subscribeToSaveResponse(this.faqService.update(this.faq)); + } else { + this.faq.faqState = FAQState.ACCEPTED; + this.subscribeToSaveResponse(this.faqService.create(this.faq)); + } + } + + /** + * @param result The Http response from the server + */ + protected subscribeToSaveResponse(result: Observable>) { + result.subscribe({ + next: (response: HttpResponse) => this.onSaveSuccess(response.body!), + error: (error: HttpErrorResponse) => this.onSaveError(error), + }); + } + + /** + * Action on successful faq creation or edit + */ + protected onSaveSuccess(faq: FAQ) { + if (!this.faq.id) { + this.faqService.find(faq.id!).subscribe({ + next: (response: HttpResponse) => { + this.isSaving = false; + const faqBody = response.body; + if (faqBody) { + this.faq = faqBody; + } + this.alertService.success(this.translateService.instant('artemisApp.faq.created', { id: faq.id })); + this.router.navigate(['course-management', faq.course!.id, 'faqs']); + }, + }); + } else { + this.isSaving = false; + this.alertService.success(this.translateService.instant('artemisApp.faq.updated', { id: faq.id })); + this.router.navigate(['course-management', faq.course!.id, 'faqs']); + } + } + + /** + * Action on unsuccessful faq creation or edit + * @param errorRes the errorRes handed to the alert service + */ + protected onSaveError(errorRes: HttpErrorResponse) { + this.isSaving = false; + if (errorRes.error?.title) { + this.alertService.addErrorAlert(errorRes.error.title, errorRes.error.message, errorRes.error.params); + } else { + onError(this.alertService, errorRes); + } + } + + updateCategories(categories: FAQCategory[]) { + this.faq.categories = categories; + this.faqCategories = categories; + } + + private loadCourseFaqCategories(courseId: number) { + loadCourseFaqCategories(courseId, this.alertService, this.faqService).subscribe((existingCategories) => { + this.existingCategories = existingCategories; + }); + } + + canSave() { + if (this.faq.questionTitle && this.faq.questionAnswer) { + return this.faq.questionTitle?.trim().length > 0 && this.faq.questionAnswer?.trim().length > 0; + } + return false; + } +} diff --git a/src/main/webapp/app/faq/faq.component.html b/src/main/webapp/app/faq/faq.component.html new file mode 100644 index 000000000000..7b0f1312fc12 --- /dev/null +++ b/src/main/webapp/app/faq/faq.component.html @@ -0,0 +1,121 @@ +
+
+
+

+ +

+
+
+
+
+ + @if (hasCategories) { +
    + @for (category of existingCategories; track category) { +
  • + +
  • + } +
+ } +
+ +
+
+
+
+ +
+ + + + + + + + + + + + @for (faq of filteredFaqs; track trackId(i, faq); let i = $index) { + + + + + + + + + } + +
+ + + + + + + + + + + +
+ {{ faq.id }} + +

+
+

+
+
+ @for (category of faq.categories; track category) { + + } +
+
+
+
+ + + + + + +
+
+
+
+
diff --git a/src/main/webapp/app/faq/faq.component.scss b/src/main/webapp/app/faq/faq.component.scss new file mode 100644 index 000000000000..6cfed334a170 --- /dev/null +++ b/src/main/webapp/app/faq/faq.component.scss @@ -0,0 +1,8 @@ +.category-badge { + margin-top: 10px; + margin-left: 4px; +} + +.category-container { + display: flex; +} diff --git a/src/main/webapp/app/faq/faq.component.ts b/src/main/webapp/app/faq/faq.component.ts new file mode 100644 index 000000000000..0ca4ed856d3c --- /dev/null +++ b/src/main/webapp/app/faq/faq.component.ts @@ -0,0 +1,123 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FAQ } from 'app/entities/faq.model'; +import { faEdit, faFile, faFileExport, faFileImport, faFilter, faPencilAlt, faPlus, faPuzzlePiece, faSort, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { Subject } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { AlertService } from 'app/core/util/alert.service'; +import { ActivatedRoute } from '@angular/router'; +import { FAQService } from 'app/faq/faq.service'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { onError } from 'app/shared/util/global.utils'; +import { FAQCategory } from 'app/entities/faq-category.model'; +import { loadCourseFaqCategories } from 'app/faq/faq.utils'; +import { SortService } from 'app/shared/service/sort.service'; +import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; + +@Component({ + selector: 'jhi-faq', + templateUrl: './faq.component.html', + styleUrls: ['./faq.component.scss'], + standalone: true, + imports: [ArtemisSharedModule, CustomExerciseCategoryBadgeComponent, ArtemisSharedComponentModule, ArtemisMarkdownModule], +}) +export class FAQComponent implements OnInit, OnDestroy { + faqs: FAQ[]; + filteredFaqs: FAQ[]; + existingCategories: FAQCategory[]; + courseId: number; + hasCategories: boolean = false; + + private dialogErrorSource = new Subject(); + dialogError$ = this.dialogErrorSource.asObservable(); + + activeFilters = new Set(); + predicate: string; + ascending: boolean; + + irisEnabled = false; + + // Icons + faEdit = faEdit; + faPlus = faPlus; + faFileImport = faFileImport; + faFileExport = faFileExport; + faTrash = faTrash; + faPencilAlt = faPencilAlt; + faFile = faFile; + faPuzzlePiece = faPuzzlePiece; + faFilter = faFilter; + faSort = faSort; + + constructor( + protected faqService: FAQService, + private route: ActivatedRoute, + private alertService: AlertService, + private sortService: SortService, + ) { + this.predicate = 'id'; + this.ascending = true; + } + + ngOnInit() { + this.courseId = Number(this.route.snapshot.paramMap.get('courseId')); + this.loadAll(); + this.loadCourseFaqCategories(this.courseId); + } + + ngOnDestroy(): void { + this.dialogErrorSource.complete(); + } + + trackId(index: number, item: FAQ) { + return item.id; + } + + deleteFaq(faqId: number) { + this.faqService.delete(faqId).subscribe({ + next: () => this.handleDeleteSuccess(faqId), + error: (error: HttpErrorResponse) => this.dialogErrorSource.next(error.message), + }); + } + + private handleDeleteSuccess(faqId: number) { + this.faqs = this.faqs.filter((faq) => faq.id !== faqId); + this.dialogErrorSource.next(''); + this.applyFilters(); + } + + toggleFilters(category: string) { + this.activeFilters = this.faqService.toggleFilter(category, this.activeFilters); + this.applyFilters(); + } + + private applyFilters(): void { + this.filteredFaqs = this.faqService.applyFilters(this.activeFilters, this.faqs); + } + + sortRows() { + this.sortService.sortByProperty(this.filteredFaqs, this.predicate, this.ascending); + } + + private loadAll() { + this.faqService + .findAllByCourseId(this.courseId) + .pipe(map((res: HttpResponse) => res.body)) + .subscribe({ + next: (res: FAQ[]) => { + this.faqs = res; + this.applyFilters(); + }, + error: (res: HttpErrorResponse) => onError(this.alertService, res), + }); + } + + private loadCourseFaqCategories(courseId: number) { + loadCourseFaqCategories(courseId, this.alertService, this.faqService).subscribe((existingCategories) => { + this.existingCategories = existingCategories; + this.hasCategories = existingCategories.length > 0; + }); + } +} diff --git a/src/main/webapp/app/faq/faq.module.ts b/src/main/webapp/app/faq/faq.module.ts new file mode 100644 index 000000000000..56765678ac1e --- /dev/null +++ b/src/main/webapp/app/faq/faq.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { FAQComponent } from 'app/faq/faq.component'; +import { faqRoutes } from 'app/faq/faq.routes'; +import { FAQUpdateComponent } from 'app/faq/faq-update.component'; +const ENTITY_STATES = [...faqRoutes]; + +@NgModule({ + imports: [RouterModule.forChild(ENTITY_STATES), FAQComponent, FAQUpdateComponent], + exports: [FAQComponent, FAQUpdateComponent], +}) +export class ArtemisFAQModule {} diff --git a/src/main/webapp/app/faq/faq.routes.ts b/src/main/webapp/app/faq/faq.routes.ts new file mode 100644 index 000000000000..ecda5cf3a525 --- /dev/null +++ b/src/main/webapp/app/faq/faq.routes.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@angular/core'; +import { HttpResponse } from '@angular/common/http'; +import { ActivatedRouteSnapshot, Resolve, Routes } from '@angular/router'; +import { UserRouteAccessService } from 'app/core/auth/user-route-access-service'; +import { Observable, of } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { Authority } from 'app/shared/constants/authority.constants'; +import { CourseManagementResolve } from 'app/course/manage/course-management-resolve.service'; +import { CourseManagementTabBarComponent } from 'app/course/manage/course-management-tab-bar/course-management-tab-bar.component'; +import { FAQComponent } from 'app/faq/faq.component'; +import { FAQService } from 'app/faq/faq.service'; +import { FAQ } from 'app/entities/faq.model'; +import { FAQUpdateComponent } from 'app/faq/faq-update.component'; + +@Injectable({ providedIn: 'root' }) +export class FAQResolve implements Resolve { + constructor(private faqService: FAQService) {} + + resolve(route: ActivatedRouteSnapshot): Observable { + const faqId = route.params['faqId']; + if (faqId) { + return this.faqService.find(faqId).pipe( + filter((response: HttpResponse) => response.ok), + map((faq: HttpResponse) => faq.body!), + ); + } + return of(new FAQ()); + } +} + +export const faqRoutes: Routes = [ + { + path: ':courseId/faqs', + component: CourseManagementTabBarComponent, + children: [ + { + path: '', + component: FAQComponent, + resolve: { + course: CourseManagementResolve, + }, + data: { + authorities: [Authority.EDITOR, Authority.INSTRUCTOR, Authority.ADMIN], + pageTitle: 'artemisApp.faq.home.title', + }, + canActivate: [UserRouteAccessService], + }, + { + // Create a new path without a component defined to prevent the FAQ from being always rendered + path: '', + resolve: { + course: CourseManagementResolve, + }, + children: [ + { + path: 'new', + component: FAQUpdateComponent, + data: { + authorities: [Authority.EDITOR, Authority.INSTRUCTOR, Authority.ADMIN], + pageTitle: 'global.generic.create', + }, + canActivate: [UserRouteAccessService], + }, + { + path: ':faqId', + resolve: { + faq: FAQResolve, + }, + children: [ + { + path: 'edit', + component: FAQUpdateComponent, + data: { + authorities: [Authority.EDITOR, Authority.INSTRUCTOR, Authority.ADMIN], + pageTitle: 'global.generic.edit', + }, + canActivate: [UserRouteAccessService], + }, + ], + }, + ], + }, + ], + }, +]; diff --git a/src/main/webapp/app/faq/faq.service.ts b/src/main/webapp/app/faq/faq.service.ts new file mode 100644 index 000000000000..059a10b95f99 --- /dev/null +++ b/src/main/webapp/app/faq/faq.service.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { FAQ, FAQState } from 'app/entities/faq.model'; +import { FAQCategory } from 'app/entities/faq-category.model'; + +type EntityResponseType = HttpResponse; +type EntityArrayResponseType = HttpResponse; + +@Injectable({ providedIn: 'root' }) +export class FAQService { + public resourceUrl = 'api/courses'; + + constructor(protected http: HttpClient) {} + + create(faq: FAQ): Observable { + const copy = FAQService.convertFaqFromClient(faq); + copy.faqState = FAQState.ACCEPTED; + return this.http.post(`api/faqs`, copy, { observe: 'response' }).pipe( + map((res: EntityResponseType) => { + return res; + }), + ); + } + + update(faq: FAQ): Observable { + const copy = FAQService.convertFaqFromClient(faq); + return this.http.put(`api/faqs/${faq.id}`, copy, { observe: 'response' }).pipe( + map((res: EntityResponseType) => { + return res; + }), + ); + } + + find(faqId: number): Observable { + return this.http.get(`api/faqs/${faqId}`, { observe: 'response' }).pipe(map((res: EntityResponseType) => FAQService.convertFaqCategoriesFromServer(res))); + } + + findAllByCourseId(courseId: number): Observable { + return this.http + .get(`${this.resourceUrl}/${courseId}/faqs`, { + observe: 'response', + }) + .pipe(map((res: EntityArrayResponseType) => FAQService.convertFaqCategoryArrayFromServer(res))); + } + + delete(faqId: number): Observable> { + return this.http.delete(`api/faqs/${faqId}`, { observe: 'response' }); + } + + findAllCategoriesByCourseId(courseId: number) { + return this.http.get(`${this.resourceUrl}/${courseId}/faq-categories`, { + observe: 'response', + }); + } + /** + * Converts the faq category json string into FaqCategory objects (if it exists). + * @param res the response + */ + static convertFaqCategoriesFromServer(res: ERT): ERT { + if (res.body?.categories) { + FAQService.parseFaqCategories(res.body); + } + return res; + } + + /** + * Converts a faqs categories into a json string (to send them to the server). Does nothing if no categories exist + * @param faq the faq + */ + static stringifyFaqCategories(faq: FAQ) { + return faq.categories?.map((category) => JSON.stringify(category) as unknown as FAQCategory); + } + + convertFaqCategoriesAsStringFromServer(categories: string[]): FAQCategory[] { + return categories.map((category) => JSON.parse(category)); + } + + /** + * Converts the faq category json strings into FaqCategory objects (if it exists). + * @param res the response + */ + static convertFaqCategoryArrayFromServer(res: EART): EART { + if (res.body) { + res.body.forEach((faq: E) => FAQService.parseFaqCategories(faq)); + } + return res; + } + + /** + * Parses the faq categories JSON string into {@link FAQCategory} objects. + * @param faq - the faq + */ + static parseFaqCategories(faq?: FAQ) { + if (faq?.categories) { + faq.categories = faq.categories.map((category) => { + const categoryObj = JSON.parse(category as unknown as string); + return new FAQCategory(categoryObj.category, categoryObj.color); + }); + } + } + + /** + * Prepare client-faq to be uploaded to the server + * @param { FAQ } faq - faq that will be modified + */ + static convertFaqFromClient(faq: F): FAQ { + const copy = Object.assign({}, faq); + copy.categories = FAQService.stringifyFaqCategories(copy); + return copy; + } + + toggleFilter(category: string, activeFilters: Set) { + if (activeFilters.has(category)) { + activeFilters.delete(category); + } else { + activeFilters.add(category); + } + return activeFilters; + } + + applyFilters(activeFilters: Set, faqs: FAQ[]): FAQ[] { + if (activeFilters.size === 0) { + // If no filters selected, show all faqs + return faqs; + } else { + return faqs.filter((faq) => this.hasFilteredCategory(faq, activeFilters)); + } + } + + hasFilteredCategory(faq: FAQ, filteredCategory: Set) { + const categories = faq.categories?.map((category) => category.category); + if (categories) { + return categories.some((category) => filteredCategory.has(category!)); + } + } +} diff --git a/src/main/webapp/app/faq/faq.utils.ts b/src/main/webapp/app/faq/faq.utils.ts new file mode 100644 index 000000000000..6c083ad5dcde --- /dev/null +++ b/src/main/webapp/app/faq/faq.utils.ts @@ -0,0 +1,23 @@ +import { onError } from 'app/shared/util/global.utils'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { AlertService } from 'app/core/util/alert.service'; +import { Observable, catchError, map, of } from 'rxjs'; +import { FAQService } from 'app/faq/faq.service'; +import { FAQCategory } from 'app/entities/faq-category.model'; + +export function loadCourseFaqCategories(courseId: number | undefined, alertService: AlertService, faqService: FAQService): Observable { + if (courseId === undefined) { + return of([]); + } + + return faqService.findAllCategoriesByCourseId(courseId).pipe( + map((categoryRes: HttpResponse) => { + const existingCategories = faqService.convertFaqCategoriesAsStringFromServer(categoryRes.body || []); + return existingCategories; + }), + catchError((error: HttpErrorResponse) => { + onError(alertService, error); + return of([]); + }), + ); +} diff --git a/src/main/webapp/app/overview/course-faq/course-faq-accordion-component.html b/src/main/webapp/app/overview/course-faq/course-faq-accordion-component.html new file mode 100644 index 000000000000..6f985dcfba2f --- /dev/null +++ b/src/main/webapp/app/overview/course-faq/course-faq-accordion-component.html @@ -0,0 +1,14 @@ +
+
+

{{faq().questionTitle}}

+ +
+ @for (category of faq().categories; track category){ + + } +
+
+
+

+
+
diff --git a/src/main/webapp/app/overview/course-faq/course-faq-accordion-component.scss b/src/main/webapp/app/overview/course-faq/course-faq-accordion-component.scss new file mode 100644 index 000000000000..0db97794d8c8 --- /dev/null +++ b/src/main/webapp/app/overview/course-faq/course-faq-accordion-component.scss @@ -0,0 +1,17 @@ +.faq-container { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + box-sizing: border-box; +} + +.faq-container h2 { + margin: 0; +} + +.badge-container { + display: flex; + margin-left: auto; + gap: 4px; +} diff --git a/src/main/webapp/app/overview/course-faq/course-faq-accordion-component.ts b/src/main/webapp/app/overview/course-faq/course-faq-accordion-component.ts new file mode 100644 index 000000000000..d0e3cba99120 --- /dev/null +++ b/src/main/webapp/app/overview/course-faq/course-faq-accordion-component.ts @@ -0,0 +1,24 @@ +import { Component, OnDestroy, input } from '@angular/core'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { FAQ } from 'app/entities/faq.model'; +import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; +import { Subject } from 'rxjs/internal/Subject'; +import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; + +@Component({ + selector: 'jhi-course-faq-accordion', + templateUrl: './course-faq-accordion-component.html', + styleUrl: './course-faq-accordion-component.scss', + standalone: true, + + imports: [TranslateDirective, CustomExerciseCategoryBadgeComponent, ArtemisMarkdownModule], +}) +export class CourseFaqAccordionComponent implements OnDestroy { + private ngUnsubscribe = new Subject(); + faq = input.required(); + + ngOnDestroy(): void { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + } +} diff --git a/src/main/webapp/app/overview/course-faq/course-faq.component.html b/src/main/webapp/app/overview/course-faq/course-faq.component.html new file mode 100644 index 000000000000..8989aa5d897c --- /dev/null +++ b/src/main/webapp/app/overview/course-faq/course-faq.component.html @@ -0,0 +1,35 @@ +
+
+
+ + @if (hasCategories) { +
    + @for (category of existingCategories; track category) { +
  • + +
  • + } +
+ } +
+
+
+ @for (faq of this.filteredFaq; track faq) { +
+ +
+ } +
+
diff --git a/src/main/webapp/app/overview/course-faq/course-faq.component.scss b/src/main/webapp/app/overview/course-faq/course-faq.component.scss new file mode 100644 index 000000000000..25093ce4e1ff --- /dev/null +++ b/src/main/webapp/app/overview/course-faq/course-faq.component.scss @@ -0,0 +1,13 @@ +.second-layer-modal-bg { + background-color: var(--secondary); +} + +.module-bg { + background-color: var(--module-bg); +} + +.scroll-container { + max-height: 80vh; + overflow-y: auto; + overflow-x: hidden; +} diff --git a/src/main/webapp/app/overview/course-faq/course-faq.component.ts b/src/main/webapp/app/overview/course-faq/course-faq.component.ts new file mode 100644 index 000000000000..eccc89768a1f --- /dev/null +++ b/src/main/webapp/app/overview/course-faq/course-faq.component.ts @@ -0,0 +1,103 @@ +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { Subject, Subscription } from 'rxjs'; +import { MetisService } from 'app/shared/metis/metis.service'; +import { faFilter, faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { ButtonType } from 'app/shared/components/button.component'; +import { SidebarData } from 'app/types/sidebar'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { CourseFaqAccordionComponent } from 'app/overview/course-faq/course-faq-accordion-component'; +import { FAQ } from 'app/entities/faq.model'; +import { FAQService } from 'app/faq/faq.service'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { AlertService } from 'app/core/util/alert.service'; +import { FAQCategory } from 'app/entities/faq-category.model'; +import { loadCourseFaqCategories } from 'app/faq/faq.utils'; +import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; +import { onError } from 'app/shared/util/global.utils'; + +@Component({ + selector: 'jhi-course-faq', + templateUrl: './course-faq.component.html', + styleUrls: ['../course-overview.scss', './course-faq.component.scss', '../../faq/faq.component.scss'], + encapsulation: ViewEncapsulation.None, + providers: [MetisService], + standalone: true, + imports: [ArtemisSharedComponentModule, ArtemisSharedModule, CourseFaqAccordionComponent, CustomExerciseCategoryBadgeComponent], +}) +export class CourseFaqComponent implements OnInit, OnDestroy { + private ngUnsubscribe = new Subject(); + private parentParamSubscription: Subscription; + + courseId: number; + faqs: FAQ[]; + + filteredFaq: FAQ[]; + existingCategories: FAQCategory[]; + activeFilters = new Set(); + + sidebarData: SidebarData; + hasCategories = false; + isCollapsed = false; + isProduction = true; + isTestServer = false; + + readonly ButtonType = ButtonType; + + // Icons + faPlus = faPlus; + faTimes = faTimes; + faFilter = faFilter; + + constructor( + private route: ActivatedRoute, + private router: Router, + private faqService: FAQService, + private alertService: AlertService, + ) {} + + ngOnInit(): void { + this.parentParamSubscription = this.route.parent!.params.subscribe((params) => { + this.courseId = Number(params.courseId); + this.loadFaqs(); + this.loadCourseExerciseCategories(this.courseId); + }); + } + + private loadCourseExerciseCategories(courseId: number) { + loadCourseFaqCategories(courseId, this.alertService, this.faqService).subscribe((existingCategories) => { + this.existingCategories = existingCategories; + this.hasCategories = existingCategories.length > 0; + }); + } + + private loadFaqs() { + this.faqService + .findAllByCourseId(this.courseId) + .pipe(map((res: HttpResponse) => res.body)) + .subscribe({ + next: (res: FAQ[]) => { + this.faqs = res; + this.applyFilters(); + }, + error: (res: HttpErrorResponse) => onError(this.alertService, res), + }); + } + + ngOnDestroy() { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + this.parentParamSubscription?.unsubscribe(); + } + + toggleFilters(category: string) { + this.activeFilters = this.faqService.toggleFilter(category, this.activeFilters); + this.applyFilters(); + } + + private applyFilters(): void { + this.filteredFaq = this.faqService.applyFilters(this.activeFilters, this.faqs); + } +} diff --git a/src/main/webapp/app/overview/course-overview.component.ts b/src/main/webapp/app/overview/course-overview.component.ts index 7cf4c805712e..9ec72eec1fdb 100644 --- a/src/main/webapp/app/overview/course-overview.component.ts +++ b/src/main/webapp/app/overview/course-overview.component.ts @@ -31,6 +31,7 @@ import { faListCheck, faNetworkWired, faPersonChalkboard, + faQuestion, faSync, faTable, faTimes, @@ -171,6 +172,7 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit faChevronRight = faChevronRight; facSidebar = facSidebar; faEllipsis = faEllipsis; + faQuestion = faQuestion; FeatureToggle = FeatureToggle; CachingStrategy = CachingStrategy; @@ -329,6 +331,17 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit sidebarItems.push(learningPathItem); } } + + if (this.course?.faqEnabled) { + const faqItem: SidebarItem = this.getFaqItem(); + sidebarItems.push(faqItem); + } + + if (this.course?.learningPathsEnabled) { + const learningPathItem: SidebarItem = this.getLearningPathItems(); + sidebarItems.push(learningPathItem); + } + return sidebarItems; } @@ -437,6 +450,19 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit return dashboardItem; } + getFaqItem() { + const faqItem: SidebarItem = { + routerLink: 'faq', + icon: faQuestion, + title: 'FAQs', + translation: 'artemisApp.courseOverview.menu.faq', + hasInOrionProperty: false, + showInOrionWindow: false, + hidden: false, + }; + return faqItem; + } + getDefaultItems() { const items = []; if (this.course?.studentCourseAnalyticsDashboardEnabled) { diff --git a/src/main/webapp/app/overview/courses-routing.module.ts b/src/main/webapp/app/overview/courses-routing.module.ts index 8b011de2206c..a499cd6ce483 100644 --- a/src/main/webapp/app/overview/courses-routing.module.ts +++ b/src/main/webapp/app/overview/courses-routing.module.ts @@ -12,6 +12,7 @@ import { CourseTutorialGroupDetailComponent } from './tutorial-group-details/cou import { ExamParticipationComponent } from 'app/exam/participate/exam-participation.component'; import { PendingChangesGuard } from 'app/shared/guard/pending-changes.guard'; import { CourseDashboardGuard } from 'app/overview/course-dashboard/course-dashboard-guard.service'; +import { CourseFaqComponent } from 'app/overview/course-faq/course-faq.component'; const routes: Routes = [ { @@ -255,6 +256,16 @@ const routes: Routes = [ pageTitle: 'overview.plagiarismCases', }, }, + { + path: 'faq', + component: CourseFaqComponent, + data: { + authorities: [Authority.USER], + pageTitle: 'overview.faq', + hasSidebar: true, + showRefreshButton: true, + }, + }, { path: '', redirectTo: 'dashboard', // dashboard will redirect to exercises if not enabled diff --git a/src/main/webapp/app/shared/category-selector/category-selector.component.ts b/src/main/webapp/app/shared/category-selector/category-selector.component.ts index d899a7b034b3..983dcc52bd9b 100644 --- a/src/main/webapp/app/shared/category-selector/category-selector.component.ts +++ b/src/main/webapp/app/shared/category-selector/category-selector.component.ts @@ -7,6 +7,7 @@ import { FormControl } from '@angular/forms'; import { MatChipInputEvent } from '@angular/material/chips'; import { Observable, map, startWith } from 'rxjs'; import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { FAQCategory } from 'app/entities/faq-category.model'; const DEFAULT_COLORS = ['#6ae8ac', '#9dca53', '#94a11c', '#691b0b', '#ad5658', '#1b97ca', '#0d3cc2', '#0ab84f']; @@ -22,12 +23,12 @@ export class CategorySelectorComponent implements OnChanges { /** * the selected categories, which can be manipulated by the user in the UI */ - @Input() categories: ExerciseCategory[]; + @Input() categories: ExerciseCategory[] | FAQCategory[]; /** * the existing categories used for auto-completion, might include duplicates */ - @Input() existingCategories: ExerciseCategory[]; + @Input() existingCategories: ExerciseCategory[] | FAQCategory[]; @Output() selectedCategories = new EventEmitter(); diff --git a/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.ts b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.ts index 8ba41f96ba8b..65640ff2925e 100644 --- a/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.ts +++ b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.ts @@ -3,6 +3,7 @@ import type { ExerciseCategory } from 'app/entities/exercise-category.model'; import { CommonModule } from '@angular/common'; import { faTimes } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { FAQCategory } from 'app/entities/faq-category.model'; type CategoryFontSize = 'default' | 'small'; @@ -16,7 +17,7 @@ type CategoryFontSize = 'default' | 'small'; export class CustomExerciseCategoryBadgeComponent { protected readonly faTimes = faTimes; - @Input({ required: true }) category: ExerciseCategory; + @Input({ required: true }) category: ExerciseCategory | FAQCategory; @Input() displayRemoveButton: boolean = false; @Input() onClick: () => void = () => {}; @Input() fontSize: CategoryFontSize = 'default'; diff --git a/src/main/webapp/i18n/de/course.json b/src/main/webapp/i18n/de/course.json index 7bc9274e7461..c952f7226a3b 100644 --- a/src/main/webapp/i18n/de/course.json +++ b/src/main/webapp/i18n/de/course.json @@ -106,6 +106,10 @@ "label": "Direktnachrichten / Gruppen-Chats aktiviert", "tooltip": "Ermöglicht den Nachrichtenaustausch in Gruppenchats oder Direktnachrichten. Alle Nutzer:innen können Direktnachrichten oder einen privaten Gruppenchat starten und andere Nutzer:innen hinzufügen. Ein Gruppenchat ist auf zehn Mitglieder:innen begrenzt. Die Chats finden im Kommunikationbereich des Kurses statt.", "codeOfConduct": "Nachrichten: Code of Conduct" + }, + "faqEnabled": { + "label": "FAQ aktivieren", + "tooltip": "Ermöglicht das Anlegen von FAQ-Einträgen, in denen Lehrende häufig gestellte Fragen übersichtlich sammeln. Studierende können auf diese Wissensbasis zugreifen, um selbstständig Themen nachzuarbeiten und offene Fragen eigenständig zu klären." } }, "enrollmentEnabled": { diff --git a/src/main/webapp/i18n/de/faq.json b/src/main/webapp/i18n/de/faq.json new file mode 100644 index 000000000000..7b8b2aa90c97 --- /dev/null +++ b/src/main/webapp/i18n/de/faq.json @@ -0,0 +1,26 @@ +{ + "artemisApp": { + "faq": { + "home": { + "title": "FAQ", + "createLabel": "FAQ erstellen", + "filterLabel": "Filter", + "createOrEditLabel": "FAQ erstellen oder bearbeiten" + }, + "created": "Das FAQ wurde erfolgreich erstellt", + "updated": "Das FAQ wurde erfolgreich aktualisiert", + "deleted": "Das FAQ wurde erfolgreich gelöscht", + "delete": { + "question": "Soll das FAQ {{ title }} wirklich dauerhaft gelöscht werden? Diese Aktion kann NICHT rückgängig gemacht werden!", + "typeNameToConfirm": "Bitte gib den Namen des FAQ zur Bestätigung ein." + }, + + "table": { + "questionTitle": "Fragentitel", + "questionAnswer": "Antwort auf die Frage", + "categories": "Kategorien" + }, + "course": "Kurs" + } + } +} diff --git a/src/main/webapp/i18n/de/global.json b/src/main/webapp/i18n/de/global.json index 9db7bb972f26..d5bd4ab9062b 100644 --- a/src/main/webapp/i18n/de/global.json +++ b/src/main/webapp/i18n/de/global.json @@ -266,7 +266,8 @@ "goBack": "Zurück", "search": "Suchen", "select": "Auswählen", - "sendToIris": "An Iris schicken" + "sendToIris": "An Iris schicken", + "faq": "FAQ" }, "detail": { "field": "Feld", @@ -345,7 +346,8 @@ "tutorialGroups": "Übungsgruppen", "statistics": "Kursstatistiken", "exams": "Klausuren", - "communication": "Kommunikation" + "communication": "Kommunikation", + "faq": "FAQ" }, "connectionStatus": { "connected": "Verbunden", diff --git a/src/main/webapp/i18n/en/course.json b/src/main/webapp/i18n/en/course.json index e977ca0d3f3f..6a6a2f819343 100644 --- a/src/main/webapp/i18n/en/course.json +++ b/src/main/webapp/i18n/en/course.json @@ -106,6 +106,10 @@ "label": "Direct Messages / Group Chats Enabled", "tooltip": "Enables messaging between course users in group chats or direct messages. Every user can start a direct message, private group chat and add other users. A group chat is limited to 10 members. The chats happens in the communication space of the course.", "codeOfConduct": "Messaging Code of Conduct" + }, + "faqEnabled": { + "label": "FAQ Enabled", + "tooltip": "Enables the creation of FAQ entries where instructors can compile frequently asked questions in an organized manner. Students can access this knowledge base to independently review topics and resolve their questions on their own." } }, "enrollmentEnabled": { diff --git a/src/main/webapp/i18n/en/faq.json b/src/main/webapp/i18n/en/faq.json new file mode 100644 index 000000000000..1a158eb52c40 --- /dev/null +++ b/src/main/webapp/i18n/en/faq.json @@ -0,0 +1,26 @@ +{ + "artemisApp": { + "faq": { + "home": { + "title": "FAQ", + "createLabel": "Create a new FAQ", + "filterLabel": "Filter", + "createOrEditLabel": "Create or edit FAQ" + }, + "created": "The FAQ was successfully created", + "updated": "The FAQ was successfully updated", + "deleted": "The FAQ was successfully deleted", + "delete": { + "question": "Are you sure you want to permanently delete the FAQ {{ title }}? This action can NOT be undone!", + "typeNameToConfirm": "Please type in the name of the FAQ to confirm." + }, + + "table": { + "questionTitle": "Question title", + "questionAnswer": "Question answer", + "categories": "Categories" + }, + "course": "Course" + } + } +} diff --git a/src/main/webapp/i18n/en/global.json b/src/main/webapp/i18n/en/global.json index 8760c0192b62..ed8f81e1fbef 100644 --- a/src/main/webapp/i18n/en/global.json +++ b/src/main/webapp/i18n/en/global.json @@ -268,7 +268,8 @@ "goBack": "Go back", "search": "Search", "select": "Select", - "sendToIris": "Send To Iris" + "sendToIris": "Send To Iris", + "faq": "FAQ" }, "detail": { "field": "Field", @@ -347,7 +348,8 @@ "exercises": "Exercises", "statistics": "Course statistics", "exams": "Exams", - "communication": "Communication" + "communication": "Communication", + "faq": "FAQ" }, "connectionStatus": { "connected": "Connected", diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index ae6d54800cd6..7d13035e00d4 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -88,7 +88,8 @@ "testExam": "Test Exam", "communication": "Communication", "plagiarismCases": "Plagiarism Cases", - "gradingSystem": "Grading System" + "gradingSystem": "Grading System", + "faq": "FAQ" }, "exerciseFilter": { "filter": "Filter", diff --git a/src/test/java/de/tum/cit/aet/artemis/FaqFactory.java b/src/test/java/de/tum/cit/aet/artemis/FaqFactory.java new file mode 100644 index 000000000000..ed782cb289ce --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/FaqFactory.java @@ -0,0 +1,28 @@ +package de.tum.cit.aet.artemis; + +import java.util.HashSet; +import java.util.Set; + +import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; +import de.tum.cit.aet.artemis.core.domain.Course; + +public class FaqFactory { + + public static Faq generateFaq(Course course, FaqState state, String title, String answer) { + Faq faq = new Faq(); + faq.setCourse(course); + faq.setFaqState(state); + faq.setQuestionTitle(title); + faq.setQuestionAnswer(answer); + faq.setCategories(generateFaqCategories()); + return faq; + } + + public static Set generateFaqCategories() { + Set categories = new HashSet<>(); + categories.add("this is a category"); + categories.add("this is also a category"); + return categories; + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/FaqIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/FaqIntegrationTest.java new file mode 100644 index 000000000000..45a9bfb47f05 --- /dev/null +++ b/src/test/java/de/tum/cit/aet/artemis/FaqIntegrationTest.java @@ -0,0 +1,115 @@ +package de.tum.cit.aet.artemis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; + +import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; +import de.tum.cit.aet.artemis.communication.repository.FaqRepository; +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; + +class FaqIntegrationTest extends AbstractSpringIntegrationIndependentTest { + + private static final String TEST_PREFIX = "faqintegrationtest"; + + @Autowired + private FaqRepository faqRepository; + + private Course course1; + + private Faq faq; + + @BeforeEach + void initTestCase() throws Exception { + int numberOfTutors = 2; + userUtilService.addUsers(TEST_PREFIX, 1, numberOfTutors, 0, 1); + List courses = courseUtilService.createCoursesWithExercisesAndLectures(TEST_PREFIX, true, true, numberOfTutors); + this.course1 = this.courseRepository.findByIdWithExercisesAndExerciseDetailsAndLecturesElseThrow(courses.getFirst().getId()); + this.faq = FaqFactory.generateFaq(course1, FaqState.ACCEPTED, "answer", "title"); + faqRepository.save(this.faq); + // Add users that are not in the course + userUtilService.createAndSaveUser(TEST_PREFIX + "student42"); + userUtilService.createAndSaveUser(TEST_PREFIX + "instructor42"); + + } + + private void testAllPreAuthorize() throws Exception { + request.postWithResponseBody("/api/faqs", new Faq(), Faq.class, HttpStatus.FORBIDDEN); + request.putWithResponseBody("/api/faqs/" + this.faq.getId(), this.faq, Faq.class, HttpStatus.FORBIDDEN); + request.delete("/api/faqs/" + this.faq.getId(), HttpStatus.FORBIDDEN); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "tutor1", roles = "TA") + void testAll_asTutor() throws Exception { + this.testAllPreAuthorize(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testAll_asStudent() throws Exception { + this.testAllPreAuthorize(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void createFaq_correctRequestBody_shouldCreateFaq() throws Exception { + Faq newFaq = FaqFactory.generateFaq(course1, FaqState.ACCEPTED, "title", "answer"); + Faq returnedFaq = request.postWithResponseBody("/api/faqs", newFaq, Faq.class, HttpStatus.CREATED); + assertThat(returnedFaq).isNotNull(); + assertThat(returnedFaq.getId()).isNotNull(); + assertThat(returnedFaq.getQuestionTitle()).isEqualTo(newFaq.getQuestionTitle()); + assertThat(returnedFaq.getCourse().getId()).isEqualTo(newFaq.getCourse().getId()); + assertThat(returnedFaq.getQuestionAnswer()).isEqualTo(newFaq.getQuestionAnswer()); + assertThat(returnedFaq.getCategories()).isEqualTo(newFaq.getCategories()); + assertThat(returnedFaq.getFaqState()).isEqualTo(newFaq.getFaqState()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void createFaq_alreadyId_shouldReturnBadRequest() throws Exception { + Faq faq = new Faq(); + faq.setId(1L); + request.postWithResponseBody("/api/faqs", faq, Faq.class, HttpStatus.BAD_REQUEST); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void updateFaq_correctRequestBody_shouldUpdateFaq() throws Exception { + Faq faq = faqRepository.findById(this.faq.getId()).orElseThrow(); + faq.setQuestionTitle("Updated"); + faq.setQuestionAnswer("Update"); + faq.setFaqState(FaqState.PROPOSED); + Set newCategories = new HashSet<>(); + newCategories.add("Test"); + faq.setCategories(newCategories); + Faq updatedFaq = request.putWithResponseBody("/api/faqs/" + faq.getId(), faq, Faq.class, HttpStatus.OK); + + assertThat(updatedFaq.getQuestionTitle()).isEqualTo("Updated"); + assertThat(updatedFaq.getQuestionAnswer()).isEqualTo("Update"); + assertThat(updatedFaq.getFaqState()).isEqualTo(FaqState.PROPOSED); + assertThat(updatedFaq.getCategories()).isEqualTo(newCategories); + assertThat(updatedFaq.getCreatedDate()).isNotNull(); + assertThat(updatedFaq.getLastModifiedDate()).isNotNull(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testGetFaqCategoriesByCourseId() throws Exception { + Faq faq = faqRepository.findById(this.faq.getId()).orElseThrow(EntityNotFoundException::new); + Set categories = faq.getCategories(); + Set returnedCategories = request.get("/api/courses/" + faq.getCourse().getId() + "/faq-categories", HttpStatus.OK, Set.class); + assertThat(categories).isEqualTo(returnedCategories); + } + +} diff --git a/src/test/javascript/spec/component/faq/faq-update.component.spec.ts b/src/test/javascript/spec/component/faq/faq-update.component.spec.ts new file mode 100644 index 000000000000..bc00d1df79bc --- /dev/null +++ b/src/test/javascript/spec/component/faq/faq-update.component.spec.ts @@ -0,0 +1,138 @@ +import { HttpResponse } from '@angular/common/http'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { HtmlForMarkdownPipe } from 'app/shared/pipes/html-for-markdown.pipe'; +import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; +import { MockRouterLinkDirective } from '../../helpers/mocks/directive/mock-router-link.directive'; +import { MockRouter } from '../../helpers/mocks/mock-router'; +import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; +import { ArtemisTestModule } from '../../test.module'; +import { FAQUpdateComponent } from 'app/faq/faq-update.component'; +import { FAQService } from 'app/faq/faq.service'; +import { FAQ, FAQState } from 'app/entities/faq.model'; +import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; +import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; +import { MockResizeObserver } from '../../helpers/mocks/service/mock-resize-observer'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { AlertService } from 'app/core/util/alert.service'; + +describe('FaqUpdateComponent', () => { + let faqUpdateComponentFixture: ComponentFixture; + let faqUpdateComponent: FAQUpdateComponent; + let faqService: FAQService; + let activatedRoute: ActivatedRoute; + let router: Router; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, MonacoEditorModule, MockModule(BrowserAnimationsModule)], + declarations: [FAQUpdateComponent, MockComponent(MonacoEditorComponent), MockPipe(HtmlForMarkdownPipe), MockRouterLinkDirective], + providers: [ + { provide: TranslateService, useClass: MockTranslateService }, + { provide: Router, useClass: MockRouter }, + { + provide: ActivatedRoute, + useValue: { + parent: { + data: of({ course: { id: 1 } }), + }, + snapshot: { + paramMap: convertToParamMap({ + courseId: '1', + }), + }, + }, + }, + MockProvider(AlertService), + ], + }).compileComponents(); + + global.ResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => { + return new MockResizeObserver(callback); + }); + faqUpdateComponentFixture = TestBed.createComponent(FAQUpdateComponent); + faqUpdateComponent = faqUpdateComponentFixture.componentInstance; + + faqService = TestBed.inject(FAQService); + + router = TestBed.inject(Router); + activatedRoute = TestBed.inject(ActivatedRoute); + faqUpdateComponentFixture.detectChanges(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should create faq', fakeAsync(() => { + faqUpdateComponent.faq = { questionTitle: 'test1' } as FAQ; + + const createSpy = jest.spyOn(faqService, 'create').mockReturnValue( + of( + new HttpResponse({ + body: { + id: 3, + questionTitle: 'test1', + course: { + id: 1, + }, + } as FAQ, + }), + ), + ); + + faqUpdateComponent.save(); + tick(); + faqUpdateComponentFixture.detectChanges(); + + expect(createSpy).toHaveBeenCalledOnce(); + expect(createSpy).toHaveBeenCalledWith({ faqState: FAQState.ACCEPTED, questionTitle: 'test1' }); + })); + + it('should edit a faq', fakeAsync(() => { + activatedRoute.parent!.data = of({ course: { id: 1 }, faq: { id: 6 } }); + + faqUpdateComponentFixture.detectChanges(); + faqUpdateComponent.faq = { id: 6, questionTitle: 'test1Updated' } as FAQ; + + const updateSpy = jest.spyOn(faqService, 'update').mockReturnValue( + of>( + new HttpResponse({ + body: { + id: 6, + title: 'test1Updated', + course: { + id: 1, + }, + } as FAQ, + }), + ), + ); + + faqUpdateComponent.save(); + tick(); + faqUpdateComponentFixture.detectChanges(); + + expect(updateSpy).toHaveBeenCalledOnce(); + expect(updateSpy).toHaveBeenCalledWith({ id: 6, questionTitle: 'test1Updated' }); + })); + + it('should navigate to previous state', fakeAsync(() => { + activatedRoute = TestBed.inject(ActivatedRoute); + activatedRoute.parent!.data = of({ course: { id: 1 }, faq: { id: 6, questionTitle: '', course: { id: 1 } } }); + + faqUpdateComponent.ngOnInit(); + faqUpdateComponentFixture.detectChanges(); + + const navigateSpy = jest.spyOn(router, 'navigate'); + const previousState = jest.spyOn(faqUpdateComponent, 'previousState'); + faqUpdateComponent.previousState(); + tick(); + expect(previousState).toHaveBeenCalledOnce(); + + const expectedPath = ['course-management', '1', 'faqs']; + expect(navigateSpy).toHaveBeenCalledWith(expectedPath); + })); +}); diff --git a/src/test/javascript/spec/component/faq/faq.component.spec.ts b/src/test/javascript/spec/component/faq/faq.component.spec.ts new file mode 100644 index 000000000000..28214f64eb44 --- /dev/null +++ b/src/test/javascript/spec/component/faq/faq.component.spec.ts @@ -0,0 +1,129 @@ +import { HttpResponse } from '@angular/common/http'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { MockComponent, MockModule, MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; +import { MockRouterLinkDirective } from '../../helpers/mocks/directive/mock-router-link.directive'; +import { MockRouter } from '../../helpers/mocks/mock-router'; +import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; +import { ArtemisTestModule } from '../../test.module'; +import { FAQService } from 'app/faq/faq.service'; +import { FAQ } from 'app/entities/faq.model'; +import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; +import { MockResizeObserver } from '../../helpers/mocks/service/mock-resize-observer'; + +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { FAQComponent } from 'app/faq/faq.component'; +import { FAQCategory } from 'app/entities/faq-category.model'; +import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; + +describe('FaqComponent', () => { + let faqComponentFixture: ComponentFixture; + let faqComponent: FAQComponent; + + let faqService: FAQService; + + let faq1: FAQ; + let faq2: FAQ; + let faq3: FAQ; + + beforeEach(() => { + faq1 = new FAQ(); + faq1.id = 1; + faq1.questionTitle = 'questionTitle'; + faq1.questionAnswer = 'questionAnswer'; + faq1.categories = [new FAQCategory('category1', '#94a11c')]; + + faq2 = new FAQ(); + faq2.id = 2; + faq2.questionTitle = 'questionTitle'; + faq2.questionAnswer = 'questionAnswer'; + faq2.categories = [new FAQCategory('category2', '#0ab84f')]; + + faq3 = new FAQ(); + faq3.id = 3; + faq3.questionTitle = 'questionTitle'; + faq3.questionAnswer = 'questionAnswer'; + faq3.categories = [new FAQCategory('category3', '#0ab84f')]; + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, ArtemisMarkdownEditorModule, MockModule(BrowserAnimationsModule)], + declarations: [FAQComponent, MockRouterLinkDirective, MockComponent(CustomExerciseCategoryBadgeComponent)], + providers: [ + { provide: TranslateService, useClass: MockTranslateService }, + { provide: Router, useClass: MockRouter }, + { + provide: ActivatedRoute, + useValue: { + parent: { + data: of({ course: { id: 1 } }), + }, + snapshot: { + paramMap: convertToParamMap({ + courseId: '1', + }), + }, + }, + }, + MockProvider(FAQService, { + findAllByCourseId: () => { + return of( + new HttpResponse({ + body: [faq1, faq2, faq3], + status: 200, + }), + ); + }, + delete: () => { + return of(new HttpResponse({ status: 200 })); + }, + findAllCategoriesByCourseId: () => { + return of( + new HttpResponse({ + body: [], + status: 200, + }), + ); + }, + applyFilters: () => { + return [faq2, faq3]; + }, + }), + ], + }) + .compileComponents() + .then(() => { + global.ResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => { + return new MockResizeObserver(callback); + }); + faqComponentFixture = TestBed.createComponent(FAQComponent); + faqComponent = faqComponentFixture.componentInstance; + + faqService = TestBed.inject(FAQService); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should fetch faqs when initialized', () => { + const findAllSpy = jest.spyOn(faqService, 'findAllByCourseId'); + + faqComponentFixture.detectChanges(); + expect(findAllSpy).toHaveBeenCalledOnce(); + expect(findAllSpy).toHaveBeenCalledWith(1); + expect(faqComponent.faqs).toHaveLength(3); + }); + + it('should delete faq', () => { + const deleteSpy = jest.spyOn(faqService, 'delete'); + faqComponentFixture.detectChanges(); + faqComponent.deleteFaq(faq1.id!); + expect(deleteSpy).toHaveBeenCalledOnce(); + expect(deleteSpy).toHaveBeenCalledWith(faq1.id!); + expect(faqComponent.faqs).toHaveLength(2); + expect(faqComponent.faqs).not.toContain(faq1); + expect(faqComponent.faqs).toEqual(faqComponent.filteredFaqs); + }); +}); diff --git a/src/test/javascript/spec/service/faq.service.spec.ts b/src/test/javascript/spec/service/faq.service.spec.ts new file mode 100644 index 000000000000..75550480aea0 --- /dev/null +++ b/src/test/javascript/spec/service/faq.service.spec.ts @@ -0,0 +1,174 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpResponse } from '@angular/common/http'; +import { take } from 'rxjs/operators'; +import { ArtemisTestModule } from '../test.module'; +import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; +import { MockSyncStorage } from '../helpers/mocks/service/mock-sync-storage.service'; +import { TranslateService } from '@ngx-translate/core'; +import { MockTranslateService } from '../helpers/mocks/service/mock-translate.service'; +import { Course } from 'app/entities/course.model'; +import { FAQ, FAQState } from 'app/entities/faq.model'; +import { FAQCategory } from 'app/entities/faq-category.model'; +import { FAQService } from 'app/faq/faq.service'; + +describe('Faq Service', () => { + let httpMock: HttpTestingController; + let service: FAQService; + const resourceUrl = 'api/faqs'; + let expectedResult: any; + let elemDefault: FAQ; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, HttpClientTestingModule], + providers: [ + { provide: LocalStorageService, useClass: MockSyncStorage }, + { provide: SessionStorageService, useClass: MockSyncStorage }, + { provide: TranslateService, useClass: MockTranslateService }, + ], + }); + service = TestBed.inject(FAQService); + httpMock = TestBed.inject(HttpTestingController); + + expectedResult = {} as HttpResponse; + elemDefault = new FAQ(); + elemDefault.questionTitle = 'Title'; + elemDefault.course = new Course(); + elemDefault.questionAnswer = 'Answer'; + elemDefault.id = 1; + elemDefault.faqState = FAQState.ACCEPTED; + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('Service methods', () => { + it('should create a faq in the database', async () => { + const returnedFromService = { ...elemDefault }; + const expected = { ...returnedFromService }; + service + .create(elemDefault) + .pipe(take(1)) + .subscribe((resp) => (expectedResult = resp)); + const req = httpMock.expectOne({ + url: resourceUrl, + method: 'POST', + }); + req.flush(returnedFromService); + expect(expectedResult.body).toEqual(expected); + }); + + it('should update a faq in the database', async () => { + const returnedFromService = { ...elemDefault }; + const expected = { ...returnedFromService }; + const faqId = elemDefault.id!; + service + .update(elemDefault) + .pipe(take(1)) + .subscribe((resp) => (expectedResult = resp)); + const req = httpMock.expectOne({ + url: `${resourceUrl}/${faqId}`, + method: 'PUT', + }); + req.flush(returnedFromService); + expect(expectedResult.body).toEqual(expected); + }); + + it('should find a faq in the database', async () => { + const category = { + color: '#6ae8ac', + category: 'category1', + } as FAQCategory; + const returnedFromService = { ...elemDefault, categories: [JSON.stringify(category)] }; + const expected = { ...elemDefault, categories: [new FAQCategory('category1', '#6ae8ac')] }; + const faqId = elemDefault.id!; + service + .find(faqId) + .pipe(take(1)) + .subscribe((resp) => (expectedResult = resp)); + const req = httpMock.expectOne({ + url: `${resourceUrl}/${faqId}`, + method: 'GET', + }); + req.flush(returnedFromService); + expect(expectedResult.body).toEqual(expected); + }); + + it('should find faqs by courseId in the database', async () => { + const category = { + color: '#6ae8ac', + category: 'category1', + } as FAQCategory; + const returnedFromService = [{ ...elemDefault, categories: [JSON.stringify(category)] }]; + const expected = [{ ...elemDefault, categories: [new FAQCategory('category1', '#6ae8ac')] }]; + const courseId = 1; + service + .findAllByCourseId(courseId) + .pipe(take(1)) + .subscribe((resp) => (expectedResult = resp)); + const req = httpMock.expectOne({ + url: `api/courses/${courseId}/faqs`, + method: 'GET', + }); + req.flush(returnedFromService); + expect(expectedResult.body).toEqual(expected); + }); + + it('should find all categories by courseId in the database', async () => { + const category = { + color: '#6ae8ac', + category: 'category1', + } as FAQCategory; + const returnedFromService = { categories: [JSON.stringify(category)] }; + const expected = { ...returnedFromService }; + const courseId = 1; + service + .findAllCategoriesByCourseId(courseId) + .pipe(take(1)) + .subscribe((resp) => (expectedResult = resp)); + const req = httpMock.expectOne({ + url: `api/courses/${courseId}/faq-categories`, + method: 'GET', + }); + req.flush(returnedFromService); + expect(expectedResult.body).toEqual(expected); + }); + + it('should set add active filter correctly', async () => { + let activeFilters = new Set(); + activeFilters = service.toggleFilter('category1', activeFilters); + + expect(activeFilters).toContain('category1'); + expect(activeFilters.size).toBe(1); + }); + + it('should remove active filter correctly', async () => { + let activeFilters = new Set(); + activeFilters.add('category1'); + activeFilters = service.toggleFilter('category1', activeFilters); + + expect(activeFilters).not.toContain('category1'); + expect(activeFilters.size).toBe(0); + }); + + it('should apply faqFilter correctly', async () => { + const activeFilters = new Set(); + activeFilters.add('test'); + const faq1 = new FAQ(); + faq1.categories = [new FAQCategory('test', 'red'), new FAQCategory('test2', 'blue')]; + + const faq11 = new FAQ(); + faq11.categories = [new FAQCategory('test', 'red'), new FAQCategory('test2', 'blue')]; + + const faq2 = new FAQ(); + faq2.categories = [new FAQCategory('testing', 'red'), new FAQCategory('test2', 'blue')]; + let filteredFaq = [faq1, faq11, faq2]; + filteredFaq = service.applyFilters(activeFilters, filteredFaq); + + expect(filteredFaq).toBeArrayOfSize(2); + expect(filteredFaq).toContainAllValues([faq1, faq11]); + }); + }); +}); diff --git a/src/test/playwright/e2e/course/CourseManagement.spec.ts b/src/test/playwright/e2e/course/CourseManagement.spec.ts index 8a7eacab4135..dc79eca9b785 100644 --- a/src/test/playwright/e2e/course/CourseManagement.spec.ts +++ b/src/test/playwright/e2e/course/CourseManagement.spec.ts @@ -23,6 +23,7 @@ const courseData = { editorGroupName: process.env.EDITOR_GROUP_NAME ?? '', instructorGroupName: process.env.INSTRUCTOR_GROUP_NAME ?? '', enableComplaints: true, + enableFaqs: true, maxComplaints: 5, maxTeamComplaints: 3, maxComplaintTimeDays: 6, @@ -100,6 +101,7 @@ test.describe('Course management', () => { await courseCreation.setCourseMaxPoints(courseData.maxPoints); await courseCreation.setProgrammingLanguage(courseData.programmingLanguage); await courseCreation.setEnableComplaints(courseData.enableComplaints); + await courseCreation.setEnableFaqs(courseData.enableFaqs); await courseCreation.setMaxComplaints(courseData.maxComplaints); await courseCreation.setMaxTeamComplaints(courseData.maxTeamComplaints); await courseCreation.setMaxComplaintsTimeDays(courseData.maxComplaintTimeDays); @@ -120,6 +122,7 @@ test.describe('Course management', () => { expect(courseBody.maxPoints).toBe(courseData.maxPoints); expect(courseBody.defaultProgrammingLanguage).toBe(courseData.programmingLanguage); expect(courseBody.complaintsEnabled).toBe(courseData.enableComplaints); + expect(courseBody.faqEnabled).toBe(courseData.enableFaqs); expect(courseBody.maxComplaints).toBe(courseData.maxComplaints); expect(courseBody.maxTeamComplaints).toBe(courseData.maxTeamComplaints); expect(courseBody.maxComplaintTimeDays).toBe(courseData.maxComplaintTimeDays); diff --git a/src/test/playwright/support/pageobjects/course/CourseCreationPage.ts b/src/test/playwright/support/pageobjects/course/CourseCreationPage.ts index 2048bdbed18d..e864f905903e 100644 --- a/src/test/playwright/support/pageobjects/course/CourseCreationPage.ts +++ b/src/test/playwright/support/pageobjects/course/CourseCreationPage.ts @@ -147,6 +147,19 @@ export class CourseCreationPage { } } + /** + * Sets if complaints are enabled + * @param complaints if complaints should be enabled + */ + async setEnableFaqs(faqs: boolean) { + const selector = this.page.locator('#field_faq_enabled'); + if (faqs) { + await selector.check(); + } else { + await selector.uncheck(); + } + } + /** * Sets maximum amount of complaints * @param maxComplaints the maximum complaints