Skip to content

Commit

Permalink
Communication: Allow users to reference FAQs in messages (#9566)
Browse files Browse the repository at this point in the history
  • Loading branch information
cremertim authored and AjayvirS committed Dec 3, 2024
1 parent e6717db commit cf2b4df
Show file tree
Hide file tree
Showing 23 changed files with 288 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -343,7 +344,7 @@ public void fetchPlagiarismCasesForCourseExercises(Set<Exercise> 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()));
Expand All @@ -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()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ public ResponseEntity<CourseForDashboardDTO> 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
Expand Down
6 changes: 5 additions & 1 deletion src/main/webapp/app/course/manage/course-update.component.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()]);
Expand Down
6 changes: 6 additions & 0 deletions src/main/webapp/app/entities/course.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
*/
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@
}
</div>
</div>
<hr class="mb-1 mt-1" />
<hr />
@if (faqs?.length === 0) {
<h2 class="markdown-preview" jhiTranslate="artemisApp.faq.noExisting"></h2>
}
<div>
@for (faq of this.filteredFaqs; track faq) {
<jhi-course-faq-accordion [faq]="faq"></jhi-course-faq-accordion>
<div #faqElement id="faq-{{ faq.id }}">
<jhi-course-faq-accordion [faq]="faq"></jhi-course-faq-accordion>
</div>
}
</div>
@if (filteredFaqs?.length === 0 && faqs.length > 0) {
Expand Down
35 changes: 33 additions & 2 deletions src/main/webapp/app/overview/course-faq/course-faq.component.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand All @@ -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<ElementRef>('faqElement');
private ngUnsubscribe = new Subject<void>();
private parentParamSubscription: Subscription;

courseId: number;
referencedFaqId: number;
faqs: Faq[];
faqState = FaqState.ACCEPTED;

Expand All @@ -52,13 +56,28 @@ 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) => {
this.courseId = Number(params.courseId);
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);
});
Expand All @@ -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),
});
Expand Down Expand Up @@ -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' });
}
}
}
4 changes: 1 addition & 3 deletions src/main/webapp/app/overview/course-overview.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}),
Expand Down
8 changes: 8 additions & 0 deletions src/main/webapp/app/shared/metis/metis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/main/webapp/app/shared/metis/metis.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export enum ReferenceType {
FILE_UPLOAD = 'file-upload',
USER = 'USER',
CHANNEL = 'CHANNEL',
FAQ = 'FAQ',
IMAGE = 'IMAGE',
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -119,6 +121,8 @@ export class PostingContentPartComponent implements OnInit {
return faFileUpload;
case ReferenceType.SLIDE:
return faFile;
case ReferenceType.FAQ:
return faQuestion;
default:
return faPaperclip;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
/(?<POST>#\d+)|(?<PROGRAMMING>\[programming].*?\[\/programming])|(?<MODELING>\[modeling].*?\[\/modeling])|(?<QUIZ>\[quiz].*?\[\/quiz])|(?<TEXT>\[text].*?\[\/text])|(?<FILE_UPLOAD>\[file-upload].*?\[\/file-upload])|(?<LECTURE>\[lecture].*?\[\/lecture])|(?<ATTACHMENT>\[attachment].*?\[\/attachment])|(?<ATTACHMENT_UNITS>\[lecture-unit].*?\[\/lecture-unit])|(?<SLIDE>\[slide].*?\[\/slide])|(?<USER>\[user].*?\[\/user])|(?<CHANNEL>\[channel].*?\[\/channel])|(?<IMAGE>!\[.*?]\(.*?\))/g;
/(?<POST>#\d+)|(?<PROGRAMMING>\[programming].*?\[\/programming])|(?<MODELING>\[modeling].*?\[\/modeling])|(?<QUIZ>\[quiz].*?\[\/quiz])|(?<TEXT>\[text].*?\[\/text])|(?<FILE_UPLOAD>\[file-upload].*?\[\/file-upload])|(?<LECTURE>\[lecture].*?\[\/lecture])|(?<ATTACHMENT>\[attachment].*?\[\/attachment])|(?<ATTACHMENT_UNITS>\[lecture-unit].*?\[\/lecture-unit])|(?<SLIDE>\[slide].*?\[\/slide])|(?<USER>\[user].*?\[\/user])|(?<CHANNEL>\[channel].*?\[\/channel])|(?<IMAGE>!\[.*?]\(.*?\))|(?<FAQ>\[faq].*?\[\/faq])/g;

// array with PatternMatch objects per reference found in the posting content
const patternMatches: PatternMatch[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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(),
Expand All @@ -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);
Expand Down
Loading

0 comments on commit cf2b4df

Please sign in to comment.