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 81aa6a27f6b0..f10248c99b81 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 @@ -57,6 +57,7 @@ import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository; import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; +import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.communication.domain.NotificationType; import de.tum.cit.aet.artemis.communication.domain.Post; import de.tum.cit.aet.artemis.communication.domain.notification.GroupNotification; @@ -343,7 +344,7 @@ public void fetchPlagiarismCasesForCourseExercises(Set exercises, Long * @param user the user entity * @return the course including exercises, lectures, exams, competencies and tutorial groups (filtered for given user) */ - public Course findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialGroupsForUser(Long courseId, User user) { + public Course findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialGroupsAndFaqForUser(Long courseId, User user) { Course course = courseRepository.findByIdWithLecturesElseThrow(courseId); // Load exercises with categories separately because this is faster than loading them with lectures and exam above (the query would become too complex) course.setExercises(exerciseRepository.findByCourseIdWithCategories(course.getId())); @@ -358,6 +359,9 @@ public Course findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialG course.setNumberOfPrerequisites(prerequisiteRepository.countByCourse(course)); // NOTE: in this call we only want to know if tutorial groups exist in the course, we will load them when the user navigates into them course.setNumberOfTutorialGroups(tutorialGroupRepository.countByCourse(course)); + if (course.isFaqEnabled()) { + course.setFaqs(faqRepository.findAllByCourseIdAndFaqState(courseId, FaqState.ACCEPTED)); + } if (authCheckService.isOnlyStudentInCourse(course, user)) { course.setExams(examRepository.filterVisibleExams(course.getExams())); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java index ba364d0c4fb5..e6af8db0cd8a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/CourseResource.java @@ -589,7 +589,7 @@ public ResponseEntity getCourseForDashboard(@PathVariable log.debug("REST request to get one course {} with exams, lectures, exercises, participations, submissions and results, etc.", courseId); User user = userRepository.getUserWithGroupsAndAuthorities(); - Course course = courseService.findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialGroupsForUser(courseId, user); + Course course = courseService.findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialGroupsAndFaqForUser(courseId, user); log.debug("courseService.findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialGroupsForUser done"); if (!authCheckService.isAtLeastStudentInCourse(course, user)) { // user might be allowed to enroll in the course diff --git a/src/main/webapp/app/course/manage/course-update.component.ts b/src/main/webapp/app/course/manage/course-update.component.ts index 08f48b72632a..fd7c49954ea4 100644 --- a/src/main/webapp/app/course/manage/course-update.component.ts +++ b/src/main/webapp/app/course/manage/course-update.component.ts @@ -1,5 +1,5 @@ import { ActivatedRoute, Router } from '@angular/router'; -import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core'; import { FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { AlertService, AlertType } from 'app/core/util/alert.service'; @@ -31,6 +31,7 @@ import { onError } from 'app/shared/util/global.utils'; import { getSemesters } from 'app/utils/semester-utils'; import { ImageCropperModalComponent } from 'app/course/manage/image-cropper-modal.component'; import { scrollToTopOfPage } from 'app/shared/util/utils'; +import { CourseStorageService } from 'app/course/manage/course-storage.service'; const DEFAULT_CUSTOM_GROUP_NAME = 'artemis-dev'; @@ -78,6 +79,8 @@ export class CourseUpdateComponent implements OnInit { isAthenaEnabled = false; tutorialGroupsFeatureActivated = false; + private courseStorageServivce = inject(CourseStorageService); + readonly semesters = getSemesters(); // NOTE: These constants are used to define the maximum length of complaints and complaint responses. @@ -344,6 +347,7 @@ export class CourseUpdateComponent implements OnInit { name: 'courseModification', content: 'Changed a course', }); + this.courseStorageServivce.updateCourse(updatedCourse!); } this.router.navigate(['course-management', updatedCourse?.id?.toString()]); diff --git a/src/main/webapp/app/entities/course.model.ts b/src/main/webapp/app/entities/course.model.ts index 6cddcfe61040..f3084853431a 100644 --- a/src/main/webapp/app/entities/course.model.ts +++ b/src/main/webapp/app/entities/course.model.ts @@ -13,6 +13,7 @@ import { TutorialGroup } from 'app/entities/tutorial-group/tutorial-group.model' import { TutorialGroupsConfiguration } from 'app/entities/tutorial-group/tutorial-groups-configuration.model'; import { LearningPath } from 'app/entities/competency/learning-path.model'; import { Prerequisite } from 'app/entities/prerequisite.model'; +import { Faq } from 'app/entities/faq.model'; export enum CourseInformationSharingConfiguration { COMMUNICATION_AND_MESSAGING = 'COMMUNICATION_AND_MESSAGING', @@ -28,6 +29,10 @@ export function isCommunicationEnabled(course: Course | undefined) { return config === CourseInformationSharingConfiguration.COMMUNICATION_AND_MESSAGING || config === CourseInformationSharingConfiguration.COMMUNICATION_ONLY; } +export function isFaqEnabled(course: Course | undefined) { + return course?.faqEnabled; +} + /** * Note: Keep in sync with method in CourseRepository.java */ @@ -98,6 +103,7 @@ export class Course implements BaseEntity { public exercises?: Exercise[]; public lectures?: Lecture[]; + public faqs?: Faq[]; public competencies?: Competency[]; public prerequisites?: Prerequisite[]; public learningPathsEnabled?: boolean; 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 index 2524b41611c7..02e740d75204 100644 --- a/src/main/webapp/app/overview/course-faq/course-faq.component.html +++ b/src/main/webapp/app/overview/course-faq/course-faq.component.html @@ -26,13 +26,15 @@ } -
+
@if (faqs?.length === 0) {

}
@for (faq of this.filteredFaqs; track faq) { - +
+ +
}
@if (filteredFaqs?.length === 0 && faqs.length > 0) { 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 index 670fa7a965d7..8138013ad4fa 100644 --- a/src/main/webapp/app/overview/course-faq/course-faq.component.ts +++ b/src/main/webapp/app/overview/course-faq/course-faq.component.ts @@ -1,6 +1,6 @@ -import { Component, OnDestroy, OnInit, ViewEncapsulation, inject } from '@angular/core'; +import { Component, ElementRef, OnDestroy, OnInit, ViewEncapsulation, effect, inject, viewChildren } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { debounceTime, map } from 'rxjs/operators'; +import { debounceTime, map, takeUntil } from 'rxjs/operators'; import { BehaviorSubject, Subject, Subscription } from 'rxjs'; import { faFilter } from '@fortawesome/free-solid-svg-icons'; import { ButtonType } from 'app/shared/components/button.component'; @@ -17,6 +17,8 @@ import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-catego import { onError } from 'app/shared/util/global.utils'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; +import { SortService } from 'app/shared/service/sort.service'; +import { Renderer2 } from '@angular/core'; @Component({ selector: 'jhi-course-faq', @@ -27,10 +29,12 @@ import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; imports: [ArtemisSharedComponentModule, ArtemisSharedModule, CourseFaqAccordionComponent, CustomExerciseCategoryBadgeComponent, SearchFilterComponent, ArtemisMarkdownModule], }) export class CourseFaqComponent implements OnInit, OnDestroy { + faqElements = viewChildren('faqElement'); private ngUnsubscribe = new Subject(); private parentParamSubscription: Subscription; courseId: number; + referencedFaqId: number; faqs: Faq[]; faqState = FaqState.ACCEPTED; @@ -52,6 +56,16 @@ export class CourseFaqComponent implements OnInit, OnDestroy { private faqService = inject(FaqService); private alertService = inject(AlertService); + private sortService = inject(SortService); + private renderer = inject(Renderer2); + + constructor() { + effect(() => { + if (this.referencedFaqId) { + this.scrollToFaq(this.referencedFaqId); + } + }); + } ngOnInit(): void { this.parentParamSubscription = this.route.parent!.params.subscribe((params) => { @@ -59,6 +73,11 @@ export class CourseFaqComponent implements OnInit, OnDestroy { this.loadFaqs(); this.loadCourseExerciseCategories(this.courseId); }); + + this.route.queryParams.pipe(takeUntil(this.ngUnsubscribe)).subscribe((params) => { + this.referencedFaqId = params['faqId']; + }); + this.searchInput.pipe(debounceTime(300)).subscribe((searchTerm: string) => { this.refreshFaqList(searchTerm); }); @@ -79,6 +98,7 @@ export class CourseFaqComponent implements OnInit, OnDestroy { next: (res: Faq[]) => { this.faqs = res; this.applyFilters(); + this.sortFaqs(); }, error: (res: HttpErrorResponse) => onError(this.alertService, res), }); @@ -114,4 +134,15 @@ export class CourseFaqComponent implements OnInit, OnDestroy { this.applyFilters(); this.applySearch(searchTerm); } + + sortFaqs() { + this.sortService.sortByProperty(this.filteredFaqs, 'id', true); + } + + scrollToFaq(faqId: number): void { + const faqElement = this.faqElements().find((faq) => faq.nativeElement.id === 'faq-' + String(faqId)); + if (faqElement) { + this.renderer.selectRootElement(faqElement.nativeElement, true).scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } } diff --git a/src/main/webapp/app/overview/course-overview.component.ts b/src/main/webapp/app/overview/course-overview.component.ts index 18ab280c3e28..8e09014c4fb1 100644 --- a/src/main/webapp/app/overview/course-overview.component.ts +++ b/src/main/webapp/app/overview/course-overview.component.ts @@ -715,9 +715,7 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit this.course = res.body; } - if (refresh) { - this.setUpConversationService(); - } + this.setUpConversationService(); setTimeout(() => (this.refreshingCourse = false), 500); // ensure min animation duration }), diff --git a/src/main/webapp/app/shared/metis/metis.service.ts b/src/main/webapp/app/shared/metis/metis.service.ts index d5c40e6dcd27..a4415a2d1cee 100644 --- a/src/main/webapp/app/shared/metis/metis.service.ts +++ b/src/main/webapp/app/shared/metis/metis.service.ts @@ -497,6 +497,14 @@ export class MetisService implements OnDestroy { } } + /** + * returns the router link required for navigating to the exercise referenced within a faq + * @return {string} router link of the faq + */ + getLinkForFaq(): string { + return `/courses/${this.getCourse().id}/faq`; + } + /** * determines the routing params required for navigating to the detail view of the given post * @param {Post} post to be navigated to diff --git a/src/main/webapp/app/shared/metis/metis.util.ts b/src/main/webapp/app/shared/metis/metis.util.ts index 3af719beb64f..535608cd0907 100644 --- a/src/main/webapp/app/shared/metis/metis.util.ts +++ b/src/main/webapp/app/shared/metis/metis.util.ts @@ -106,6 +106,7 @@ export enum ReferenceType { FILE_UPLOAD = 'file-upload', USER = 'USER', CHANNEL = 'CHANNEL', + FAQ = 'FAQ', IMAGE = 'IMAGE', } diff --git a/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts b/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts index 0f2ce0b923ae..6c8322045888 100644 --- a/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts +++ b/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts @@ -15,6 +15,7 @@ import { faMessage, faPaperclip, faProjectDiagram, + faQuestion, } from '@fortawesome/free-solid-svg-icons'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { EnlargeSlideImageComponent } from 'app/shared/metis/posting-content/enlarge-slide-image/enlarge-slide-image.component'; @@ -43,6 +44,7 @@ export class PostingContentPartComponent implements OnInit { protected readonly faBan = faBan; protected readonly faAt = faAt; protected readonly faHashtag = faHashtag; + protected readonly faQuestion = faQuestion; protected readonly ReferenceType = ReferenceType; processedContentBeforeReference: string; @@ -119,6 +121,8 @@ export class PostingContentPartComponent implements OnInit { return faFileUpload; case ReferenceType.SLIDE: return faFile; + case ReferenceType.FAQ: + return faQuestion; default: return faPaperclip; } diff --git a/src/main/webapp/app/shared/metis/posting-content/posting-content.components.ts b/src/main/webapp/app/shared/metis/posting-content/posting-content.components.ts index 59de56813a99..2d8b84e2b71b 100644 --- a/src/main/webapp/app/shared/metis/posting-content/posting-content.components.ts +++ b/src/main/webapp/app/shared/metis/posting-content/posting-content.components.ts @@ -128,6 +128,12 @@ export class PostingContentComponent implements OnInit, OnChanges, OnDestroy { // linkToReference: link to be navigated to on reference click referenceStr = this.content.substring(this.content.indexOf(']', patternMatch.startIndex)! + 1, this.content.indexOf('(', patternMatch.startIndex)!); linkToReference = [this.content.substring(this.content.indexOf('(', patternMatch.startIndex)! + 1, this.content.indexOf(')', patternMatch.startIndex))]; + } else if (ReferenceType.FAQ === referenceType) { + referenceStr = this.content.substring(this.content.indexOf(']', patternMatch.startIndex)! + 1, this.content.indexOf('(/courses', patternMatch.startIndex)!); + linkToReference = [ + this.content.substring(this.content.indexOf('(/courses', patternMatch.startIndex)! + 1, this.content.indexOf('?faqId', patternMatch.startIndex)), + ]; + queryParams = { faqId: this.content.substring(this.content.indexOf('=') + 1, this.content.indexOf(')')) } as Params; } else if (ReferenceType.ATTACHMENT === referenceType || ReferenceType.ATTACHMENT_UNITS === referenceType) { // referenceStr: string to be displayed for the reference // attachmentToReference: location of attachment to be opened on reference click @@ -217,9 +223,10 @@ export class PostingContentComponent implements OnInit, OnChanges, OnDestroy { // Group 8: reference pattern for Lecture Units // Group 9: reference pattern for Users // Group 10: pattern for embedded images + // Group 11: reference pattern for FAQ // globally searched for, i.e. no return after first match const pattern = - /(?#\d+)|(?\[programming].*?\[\/programming])|(?\[modeling].*?\[\/modeling])|(?\[quiz].*?\[\/quiz])|(?\[text].*?\[\/text])|(?\[file-upload].*?\[\/file-upload])|(?\[lecture].*?\[\/lecture])|(?\[attachment].*?\[\/attachment])|(?\[lecture-unit].*?\[\/lecture-unit])|(?\[slide].*?\[\/slide])|(?\[user].*?\[\/user])|(?\[channel].*?\[\/channel])|(?!\[.*?]\(.*?\))/g; + /(?#\d+)|(?\[programming].*?\[\/programming])|(?\[modeling].*?\[\/modeling])|(?\[quiz].*?\[\/quiz])|(?\[text].*?\[\/text])|(?\[file-upload].*?\[\/file-upload])|(?\[lecture].*?\[\/lecture])|(?\[attachment].*?\[\/attachment])|(?\[lecture-unit].*?\[\/lecture-unit])|(?\[slide].*?\[\/slide])|(?\[user].*?\[\/user])|(?\[channel].*?\[\/channel])|(?!\[.*?]\(.*?\))|(?\[faq].*?\[\/faq])/g; // array with PatternMatch objects per reference found in the posting content const patternMatches: PatternMatch[] = []; diff --git a/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts b/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts index 129273c97338..03a87624d52f 100644 --- a/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts +++ b/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts @@ -21,7 +21,7 @@ import { MetisService } from 'app/shared/metis/metis.service'; import { LectureService } from 'app/lecture/lecture.service'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { ChannelService } from 'app/shared/metis/conversations/channel.service'; -import { isCommunicationEnabled } from 'app/entities/course.model'; +import { isCommunicationEnabled, isFaqEnabled } from 'app/entities/course.model'; import { TextEditorAction } from 'app/shared/monaco-editor/model/actions/text-editor-action.model'; import { BoldAction } from 'app/shared/monaco-editor/model/actions/bold.action'; import { ItalicAction } from 'app/shared/monaco-editor/model/actions/italic.action'; @@ -34,6 +34,7 @@ import { ChannelReferenceAction } from 'app/shared/monaco-editor/model/actions/c import { UserMentionAction } from 'app/shared/monaco-editor/model/actions/communication/user-mention.action'; import { ExerciseReferenceAction } from 'app/shared/monaco-editor/model/actions/communication/exercise-reference.action'; import { LectureAttachmentReferenceAction } from 'app/shared/monaco-editor/model/actions/communication/lecture-attachment-reference.action'; +import { FaqReferenceAction } from 'app/shared/monaco-editor/model/actions/communication/faq-reference.action'; import { UrlAction } from 'app/shared/monaco-editor/model/actions/url.action'; import { AttachmentAction } from 'app/shared/monaco-editor/model/actions/attachment.action'; import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; @@ -96,6 +97,8 @@ export class PostingMarkdownEditorComponent implements OnInit, ControlValueAcces ? [new UserMentionAction(this.courseManagementService, this.metisService), new ChannelReferenceAction(this.metisService, this.channelService)] : []; + const faqAction = isFaqEnabled(this.metisService.getCourse()) ? [new FaqReferenceAction(this.metisService)] : []; + this.defaultActions = [ new BoldAction(), new ItalicAction(), @@ -111,6 +114,7 @@ export class PostingMarkdownEditorComponent implements OnInit, ControlValueAcces new AttachmentAction(), ...messagingOnlyActions, new ExerciseReferenceAction(this.metisService), + ...faqAction, ]; this.lectureAttachmentReferenceAction = new LectureAttachmentReferenceAction(this.metisService, this.lectureService); diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/faq-reference.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/faq-reference.action.ts new file mode 100644 index 000000000000..b53d7b5960fa --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/faq-reference.action.ts @@ -0,0 +1,72 @@ +import { TranslateService } from '@ngx-translate/core'; +import { MetisService } from 'app/shared/metis/metis.service'; +import { TextEditorDomainActionWithOptions } from 'app/shared/monaco-editor/model/actions/text-editor-domain-action-with-options.model'; +import { ValueItem } from 'app/shared/markdown-editor/value-item.model'; +import { Disposable } from 'app/shared/monaco-editor/model/actions/monaco-editor.util'; +import { TextEditor } from 'app/shared/monaco-editor/model/actions/adapter/text-editor.interface'; +import { TextEditorCompletionItem, TextEditorCompletionItemKind } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-completion-item.model'; +import { TextEditorRange } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-range.model'; + +/** + * Action to insert a reference to a faq into the editor. Users that type a / will see a list of available faqs to reference. + */ +export class FaqReferenceAction extends TextEditorDomainActionWithOptions { + static readonly ID = 'faq-reference.action'; + static readonly DEFAULT_INSERT_TEXT = '/faq'; + + disposableCompletionProvider?: Disposable; + + constructor(private readonly metisService: MetisService) { + super(FaqReferenceAction.ID, 'artemisApp.metis.editor.faq'); + } + + /** + * Registers this action in the provided editor. This will register a completion provider that shows the available faqs. + * @param editor The editor to register the completion provider for. + * @param translateService The translate service to use for translations. + */ + register(editor: TextEditor, translateService: TranslateService): void { + super.register(editor, translateService); + const faqs = this.metisService.getCourse().faqs ?? []; + this.setValues( + faqs.map((faq) => ({ + id: faq.id!.toString(), + value: faq.questionTitle!, + type: 'faq', + })), + ); + + this.disposableCompletionProvider = this.registerCompletionProviderForCurrentModel( + editor, + () => Promise.resolve(this.getValues()), + (item: ValueItem, range: TextEditorRange) => + new TextEditorCompletionItem( + `/faq ${item.value}`, + item.type, + `[${item.type}]${item.value}(${this.metisService.getLinkForFaq()}?faqId=${item.id})[/${item.type}]`, + TextEditorCompletionItemKind.Default, + range, + ), + '/', + ); + } + + /** + * Inserts the text '/faq' into the editor and focuses it. This method will trigger the completion provider to show the available faqs. + * @param editor The editor to insert the text into. + */ + run(editor: TextEditor): void { + this.replaceTextAtCurrentSelection(editor, FaqReferenceAction.DEFAULT_INSERT_TEXT); + editor.triggerCompletion(); + editor.focus(); + } + + dispose(): void { + super.dispose(); + this.disposableCompletionProvider?.dispose(); + } + + getOpeningIdentifier(): string { + return '[faq]'; + } +} diff --git a/src/main/webapp/i18n/de/metis.json b/src/main/webapp/i18n/de/metis.json index e6ab066ec220..f0994d9284b1 100644 --- a/src/main/webapp/i18n/de/metis.json +++ b/src/main/webapp/i18n/de/metis.json @@ -47,7 +47,8 @@ "exercise": "Aufgabe", "lecture": "Vortrag", "channel": "Kanal", - "user": "Benutzer" + "user": "Benutzer", + "faq": "FAQ" }, "channel": { "noChannel": "Es ist kein Kanal verfügbar.", diff --git a/src/main/webapp/i18n/en/metis.json b/src/main/webapp/i18n/en/metis.json index d983860db3c0..1c604a215ce0 100644 --- a/src/main/webapp/i18n/en/metis.json +++ b/src/main/webapp/i18n/en/metis.json @@ -47,7 +47,8 @@ "exercise": "Exercise", "lecture": "Lecture", "channel": "Channel", - "user": "User" + "user": "User", + "faq": "FAQ" }, "channel": { "noChannel": "There is no channel available.", diff --git a/src/test/java/de/tum/cit/aet/artemis/core/DatabaseQueryCountTest.java b/src/test/java/de/tum/cit/aet/artemis/core/DatabaseQueryCountTest.java index 8debc7999553..9ba2bf3e3ef7 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/DatabaseQueryCountTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/DatabaseQueryCountTest.java @@ -72,12 +72,14 @@ void testGetAllCoursesForDashboardRealisticQueryCount() throws Exception { // 1 DB call to get the batch of a live quiz. No Batches of other quizzes are retrieved var course = courses.getFirst(); + // potentially, we might get a course that has faqs disabled, in which case we would have 12 calls instead of 13 + int numberOfCounts = course.isFaqEnabled() ? 13 : 12; assertThatDb(() -> { log.info("Start course for dashboard call for one course"); var userCourse = request.get("/api/courses/" + course.getId() + "/for-dashboard", HttpStatus.OK, Course.class); log.info("Finish courses for dashboard call for one course"); return userCourse; - }).hasBeenCalledTimes(12); // TODO: reduce this number back to 11 + }).hasBeenCalledTimes(numberOfCounts); // 1 DB call to get the user from the DB // 1 DB call to get the course with lectures // 1 DB call to load all exercises with categories @@ -88,6 +90,7 @@ void testGetAllCoursesForDashboardRealisticQueryCount() throws Exception { // 1 DB call to get all plagiarism cases // 1 DB call to get the grading scale // 1 DB call to get the batch of a live quiz. No Batches of other quizzes are retrieved + // 1 DB call to get the faqs, if they are enabled } @Test diff --git a/src/test/javascript/spec/component/overview/course-faq/course-faq.component.spec.ts b/src/test/javascript/spec/component/overview/course-faq/course-faq.component.spec.ts index b02b4b4dcb44..b4c03e11bf60 100644 --- a/src/test/javascript/spec/component/overview/course-faq/course-faq.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-faq/course-faq.component.spec.ts @@ -19,6 +19,8 @@ import { CourseFaqAccordionComponent } from 'app/overview/course-faq/course-faq- import { Faq, FaqState } from 'app/entities/faq.model'; import { FaqCategory } from 'app/entities/faq-category.model'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; +import { SortService } from 'app/shared/service/sort.service'; +import { ElementRef, signal } from '@angular/core'; function createFaq(id: number, category: string, color: string): Faq { const faq = new Faq(); @@ -36,6 +38,7 @@ describe('CourseFaqs', () => { let faqService: FaqService; let alertServiceStub: jest.SpyInstance; let alertService: AlertService; + let sortService: SortService; let faq1: Faq; let faq2: Faq; @@ -66,6 +69,7 @@ describe('CourseFaqs', () => { parent: { params: of({ courseId: '1' }), }, + queryParams: of({ faqId: '1' }), }, }, MockProvider(FaqService, { @@ -104,6 +108,7 @@ describe('CourseFaqs', () => { faqService = TestBed.inject(FaqService); alertService = TestBed.inject(AlertService); + sortService = TestBed.inject(SortService); }); }); @@ -156,4 +161,24 @@ describe('CourseFaqs', () => { courseFaqComponentFixture.detectChanges(); expect(alertServiceStub).toHaveBeenCalledOnce(); }); + + it('should call sortService when sortRows is called', () => { + jest.spyOn(sortService, 'sortByProperty').mockReturnValue([]); + courseFaqComponent.sortFaqs(); + expect(sortService.sortByProperty).toHaveBeenCalledOnce(); + }); + + it('should scroll and focus on the faq element with given id', () => { + const nativeElement1 = { id: 'faq-1', scrollIntoView: jest.fn(), focus: jest.fn() }; + const nativeElement2 = { id: 'faq-2', scrollIntoView: jest.fn(), focus: jest.fn() }; + + const elementRef1 = new ElementRef(nativeElement1); + const elementRef2 = new ElementRef(nativeElement2); + + courseFaqComponent.faqElements = signal([elementRef1, elementRef2]); + + courseFaqComponent.scrollToFaq(1); + + expect(nativeElement1.scrollIntoView).toHaveBeenCalledExactlyOnceWith({ behavior: 'smooth', block: 'start' }); + }); }); diff --git a/src/test/javascript/spec/component/shared/metis/posting-content/posting-content.component.spec.ts b/src/test/javascript/spec/component/shared/metis/posting-content/posting-content.component.spec.ts index 00be6399589b..6ff1d855bbb7 100644 --- a/src/test/javascript/spec/component/shared/metis/posting-content/posting-content.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/posting-content/posting-content.component.spec.ts @@ -506,6 +506,22 @@ describe('PostingContentComponent', () => { ]); })); + it('should compute parts when referencing a faq', () => { + component.content = `I want to reference [faq]faq(/courses/1/faq?faqId=45)[/faq].`; + const matches = component.getPatternMatches(); + component.computePostingContentParts(matches); + expect(component.postingContentParts()).toEqual([ + { + contentBeforeReference: 'I want to reference ', + linkToReference: ['/courses/1/faq'], + referenceStr: `faq`, + referenceType: ReferenceType.FAQ, + contentAfterReference: '.', + queryParams: { faqId: '45' }, + } as PostingContentPart, + ]); + }); + it('should compute parts when referencing a channel', fakeAsync(() => { component.content = `This topic belongs to [channel]test(1)[/channel].`; const matches = component.getPatternMatches(); diff --git a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts index 53b303c4c9f6..10a6a49276f9 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts @@ -8,7 +8,7 @@ import { MetisService } from 'app/shared/metis/metis.service'; import { MockMetisService } from '../../../../helpers/mocks/service/mock-metis-service.service'; import { metisAnswerPostUser2, metisPostExerciseUser1 } from '../../../../helpers/sample/metis-sample-data'; import { LectureService } from 'app/lecture/lecture.service'; -import { Subject } from 'rxjs'; +import { Subject, of } from 'rxjs'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { ChannelService } from 'app/shared/metis/conversations/channel.service'; import * as CourseModel from 'app/entities/course.model'; @@ -23,19 +23,20 @@ import { CodeAction } from 'app/shared/monaco-editor/model/actions/code.action'; import { CodeBlockAction } from 'app/shared/monaco-editor/model/actions/code-block.action'; import { ExerciseReferenceAction } from 'app/shared/monaco-editor/model/actions/communication/exercise-reference.action'; import { LectureAttachmentReferenceAction } from 'app/shared/monaco-editor/model/actions/communication/lecture-attachment-reference.action'; -import { UrlAction } from '../../../../../../../main/webapp/app/shared/monaco-editor/model/actions/url.action'; -import { AttachmentAction } from '../../../../../../../main/webapp/app/shared/monaco-editor/model/actions/attachment.action'; +import { FaqReferenceAction } from 'app/shared/monaco-editor/model/actions/communication/faq-reference.action'; +import { UrlAction } from 'app/shared/monaco-editor/model/actions/url.action'; +import { AttachmentAction } from 'app/shared/monaco-editor/model/actions/attachment.action'; import { EmojiAction } from 'app/shared/monaco-editor/model/actions/emoji.action'; import { Overlay, OverlayPositionBuilder } from '@angular/cdk/overlay'; import { TextEditor } from 'app/shared/monaco-editor/model/actions/adapter/text-editor.interface'; import { ComponentPortal } from '@angular/cdk/portal'; +import { HttpResponse } from '@angular/common/http'; import { TextEditorAction } from 'app/shared/monaco-editor/model/actions/text-editor-action.model'; import { TextEditorRange } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-range.model'; import { TextEditorPosition } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-position.model'; import { BulletedListAction } from 'app/shared/monaco-editor/model/actions/bulleted-list.action'; import { OrderedListAction } from 'app/shared/monaco-editor/model/actions/ordered-list.action'; import { ListAction } from '../../../../../../../main/webapp/app/shared/monaco-editor/model/actions/list.action'; -import { StrikethroughAction } from '../../../../../../../main/webapp/app/shared/monaco-editor/model/actions/strikethrough.action'; describe('PostingsMarkdownEditor', () => { let component: PostingMarkdownEditorComponent; @@ -44,6 +45,7 @@ describe('PostingsMarkdownEditor', () => { let mockMarkdownEditorComponent: MarkdownEditorMonacoComponent; let metisService: MetisService; let lectureService: LectureService; + let findLectureWithDetailsSpy: jest.SpyInstance; const backdropClickSubject = new Subject(); const mockOverlayRef = { @@ -132,6 +134,10 @@ describe('PostingsMarkdownEditor', () => { debugElement = fixture.debugElement; metisService = TestBed.inject(MetisService); lectureService = TestBed.inject(LectureService); + + findLectureWithDetailsSpy = jest.spyOn(lectureService, 'findAllByCourseIdWithSlides'); + const returnValue = of(new HttpResponse({ body: [], status: 200 })); + findLectureWithDetailsSpy.mockReturnValue(returnValue); fixture.autoDetectChanges(); mockMarkdownEditorComponent = fixture.debugElement.query(By.directive(MarkdownEditorMonacoComponent)).componentInstance; component.ngOnInit(); @@ -143,27 +149,8 @@ describe('PostingsMarkdownEditor', () => { it('should have set the correct default commands on init if messaging or communication is enabled', () => { component.ngOnInit(); - - expect(component.defaultActions).toEqual( - expect.arrayContaining([ - expect.any(BoldAction), - expect.any(ItalicAction), - expect.any(UnderlineAction), - expect.any(QuoteAction), - expect.any(CodeAction), - expect.any(CodeBlockAction), - expect.any(OrderedListAction), - expect.any(BulletedListAction), - expect.any(StrikethroughAction), - expect.any(UnderlineAction), - expect.any(EmojiAction), - expect.any(UrlAction), - expect.any(AttachmentAction), - expect.any(UserMentionAction), - expect.any(ChannelReferenceAction), - expect.any(ExerciseReferenceAction), - ]), - ); + containDefaultActions(component.defaultActions); + expect(component.defaultActions).toEqual(expect.arrayContaining([expect.any(UserMentionAction), expect.any(ChannelReferenceAction)])); expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService)); }); @@ -171,8 +158,12 @@ describe('PostingsMarkdownEditor', () => { it('should have set the correct default commands on init if communication is disabled', () => { jest.spyOn(CourseModel, 'isCommunicationEnabled').mockReturnValueOnce(false); component.ngOnInit(); + containDefaultActions(component.defaultActions); + expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService)); + }); - expect(component.defaultActions).toEqual( + function containDefaultActions(defaultActions: TextEditorAction[]) { + expect(defaultActions).toEqual( expect.arrayContaining([ expect.any(BoldAction), expect.any(ItalicAction), @@ -186,7 +177,21 @@ describe('PostingsMarkdownEditor', () => { expect.any(ExerciseReferenceAction), ]), ); + } + it('should have set the correct default commands on init if faq is enabled', () => { + jest.spyOn(CourseModel, 'isFaqEnabled').mockReturnValueOnce(true); + component.ngOnInit(); + containDefaultActions(component.defaultActions); + expect(component.defaultActions).toEqual(expect.arrayContaining([expect.any(FaqReferenceAction)])); + expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService)); + }); + + it('should have set the correct default commands on init if faq is disabled', () => { + jest.spyOn(CourseModel, 'isFaqEnabled').mockReturnValueOnce(false); + component.ngOnInit(); + containDefaultActions(component.defaultActions); + expect(component.defaultActions).toEqual(expect.not.arrayContaining([expect.any(FaqReferenceAction)])); expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService)); }); diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts index fd1179a35c4b..9b1c935ded18 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts @@ -28,6 +28,8 @@ import { LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; import { ReferenceType } from 'app/shared/metis/metis.util'; import { Attachment } from 'app/entities/attachment.model'; import dayjs from 'dayjs/esm'; +import { FaqReferenceAction } from 'app/shared/monaco-editor/model/actions/communication/faq-reference.action'; +import { Faq } from 'app/entities/faq.model'; describe('MonacoEditorCommunicationActionIntegration', () => { let comp: MonacoEditorComponent; @@ -42,6 +44,7 @@ describe('MonacoEditorCommunicationActionIntegration', () => { let channelReferenceAction: ChannelReferenceAction; let userMentionAction: UserMentionAction; let exerciseReferenceAction: ExerciseReferenceAction; + let faqReferenceAction: FaqReferenceAction; beforeEach(() => { return TestBed.configureTestingModule({ @@ -69,6 +72,7 @@ describe('MonacoEditorCommunicationActionIntegration', () => { channelReferenceAction = new ChannelReferenceAction(metisService, channelService); userMentionAction = new UserMentionAction(courseManagementService, metisService); exerciseReferenceAction = new ExerciseReferenceAction(metisService); + faqReferenceAction = new FaqReferenceAction(metisService); }); }); @@ -92,11 +96,13 @@ describe('MonacoEditorCommunicationActionIntegration', () => { { actionId: ChannelReferenceAction.ID, defaultInsertText: '#', triggerCharacter: '#' }, { actionId: UserMentionAction.ID, defaultInsertText: '@', triggerCharacter: '@' }, { actionId: ExerciseReferenceAction.ID, defaultInsertText: '/exercise', triggerCharacter: '/' }, + { actionId: FaqReferenceAction.ID, defaultInsertText: '/faq', triggerCharacter: '/' }, ])('Suggestions and default behavior for $actionId', ({ actionId, defaultInsertText, triggerCharacter }) => { - let action: ChannelReferenceAction | UserMentionAction | ExerciseReferenceAction; + let action: ChannelReferenceAction | UserMentionAction | ExerciseReferenceAction | FaqReferenceAction; let channels: ChannelIdAndNameDTO[]; let users: User[]; let exercises: Exercise[]; + let faqs: Faq[]; beforeEach(() => { fixture.detectChanges(); @@ -106,6 +112,7 @@ describe('MonacoEditorCommunicationActionIntegration', () => { users = [metisUser1, metisUser2, metisTutor]; jest.spyOn(courseManagementService, 'searchMembersForUserMentions').mockReturnValue(of(new HttpResponse({ body: users, status: 200 }))); exercises = metisService.getCourse().exercises!; + faqs = metisService.getCourse().faqs!; switch (actionId) { case ChannelReferenceAction.ID: @@ -117,6 +124,9 @@ describe('MonacoEditorCommunicationActionIntegration', () => { case ExerciseReferenceAction.ID: action = exerciseReferenceAction; break; + case FaqReferenceAction.ID: + action = faqReferenceAction; + break; } }); @@ -173,6 +183,15 @@ describe('MonacoEditorCommunicationActionIntegration', () => { }); }; + const checkFaqSuggestions = (suggestions: monaco.languages.CompletionItem[], faqs: Faq[]) => { + expect(suggestions).toHaveLength(faqs.length); + suggestions.forEach((suggestion, index) => { + expect(suggestion.label).toBe(`/faq ${faqs[index].questionTitle}`); + expect(suggestion.insertText).toBe(`[faq]${faqs[index].questionTitle}(${metisService.getLinkForFaq()}?faqId=${faqs[index].id})[/faq]`); + expect(suggestion.detail).toBe('faq'); + }); + }; + it.each(['', 'ex'])('should suggest the correct values if the user is typing a reference (suffix "%s")', async (referenceSuffix: string) => { const reference = triggerCharacter + referenceSuffix; comp.setText(reference); @@ -192,6 +211,9 @@ describe('MonacoEditorCommunicationActionIntegration', () => { case ExerciseReferenceAction.ID: checkExerciseSuggestions(suggestions, exercises); break; + case FaqReferenceAction.ID: + checkFaqSuggestions(suggestions, faqs); + break; } }); }); @@ -232,6 +254,23 @@ describe('MonacoEditorCommunicationActionIntegration', () => { comp.registerAction(exerciseReferenceAction); expect(exerciseReferenceAction.getValues()).toEqual([]); }); + + it('should insert / for faq references', () => { + fixture.detectChanges(); + comp.registerAction(faqReferenceAction); + faqReferenceAction.executeInCurrentEditor(); + expect(comp.getText()).toBe('/faq'); + }); + }); + + describe('FaqReferenceAction', () => { + it('should initialize with empty values if faqs are not available', () => { + jest.spyOn(metisService, 'getCourse').mockReturnValue({ faqs: undefined } as any); + + fixture.detectChanges(); + comp.registerAction(faqReferenceAction); + expect(faqReferenceAction.getValues()).toEqual([]); + }); }); describe('LectureAttachmentReferenceAction', () => { diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-metis-service.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-metis-service.service.ts index 5fa7943e5d7e..809f4991dc9f 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-metis-service.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-metis-service.service.ts @@ -96,6 +96,10 @@ export class MockMetisService { return '/courses/' + metisCourse.id + '/exams/' + examId; } + getLinkForFaq(): string { + return `/courses/${this.getCourse().id}/faq`; + } + getLinkForChannelSubType(channel?: ChannelDTO): string | undefined { const referenceId = channel?.subTypeReferenceId?.toString(); if (!referenceId) { diff --git a/src/test/javascript/spec/helpers/sample/metis-sample-data.ts b/src/test/javascript/spec/helpers/sample/metis-sample-data.ts index 89a12b96ad25..8230382a804e 100644 --- a/src/test/javascript/spec/helpers/sample/metis-sample-data.ts +++ b/src/test/javascript/spec/helpers/sample/metis-sample-data.ts @@ -39,6 +39,10 @@ export const metisUpVoteReactionUser1 = { id: 1, user: metisUser1, emojiId: VOTE export const metisReactionUser2 = { id: 2, user: metisUser2, emojiId: 'smile', creationDate: undefined } as Reaction; export const metisReactionToCreate = { emojiId: 'cheerio', creationDate: undefined } as Reaction; +export const metisFaq1 = { id: 1, questionTitle: 'title', questionAnswer: 'answer' }; +export const metisFaq2 = { id: 2, questionTitle: 'title', questionAnswer: 'answer' }; +export const metisFaq3 = { id: 3, questionTitle: 'title', questionAnswer: 'answer' }; + export const metisCourse = { id: 1, title: 'Metis Course', @@ -46,6 +50,7 @@ export const metisCourse = { lectures: [metisLecture, metisLecture2, metisLecture3], courseInformationSharingConfiguration: CourseInformationSharingConfiguration.COMMUNICATION_AND_MESSAGING, groups: ['metisTutors', 'metisStudents', 'metisInstructors'], + faqs: [metisFaq1, metisFaq2, metisFaq3], } as Course; export const metisResolvingAnswerPostUser1 = { diff --git a/src/test/javascript/spec/service/metis/metis.service.spec.ts b/src/test/javascript/spec/service/metis/metis.service.spec.ts index 17f174695dee..0e738ebfac68 100644 --- a/src/test/javascript/spec/service/metis/metis.service.spec.ts +++ b/src/test/javascript/spec/service/metis/metis.service.spec.ts @@ -337,6 +337,12 @@ describe('Metis Service', () => { expect(referenceRouterLink).toBe(`/courses/${metisCourse.id}/exams/${metisExam.id!.toString()}`); }); + it('should determine the router link required for referencing a faq', () => { + metisService.setCourse(course); + const link = metisService.getLinkForFaq(); + expect(link).toBe(`/courses/${metisCourse.id}/faq`); + }); + it('should determine the router link required for navigation based on the channel subtype', () => { metisService.setCourse(course); const channelDTO = new ChannelDTO();