-
Notifications
You must be signed in to change notification settings - Fork 303
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Communication
: Allow users to search for answer post in course wide channels
#9616
Changes from all commits
cb29b7b
0660599
f91168d
61ea920
dc8c74d
6518cf5
a3f8ba3
22706e3
c617c83
f7683a0
54fc115
86c9383
f1f99f9
5860f85
fe73a9a
b999232
81857de
436aa5a
2c00600
0f42275
6885814
ea0d825
7566bc6
509eb8b
23d9633
6f53284
89fe6a2
7b0876d
47c6d5d
45befc9
c9f2dbd
f1562b2
dbe5e5f
c45068b
ffbac97
676d211
6234eab
86594a3
17755b8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'; | ||||||
|
@@ -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<ElementRef>('faqElement'); | ||||||
private ngUnsubscribe = new Subject<void>(); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the Currently, Apply this diff to correct the property declaration: -faqElements = viewChildren<ElementRef>('faqElement');
+@ViewChildren('faqElement') faqElements: QueryList<ElementRef>; Also, ensure you import +import { ..., QueryList } from '@angular/core';
|
||||||
private parentParamSubscription: Subscription; | ||||||
|
||||||
courseId: number; | ||||||
referencedFaqId: number; | ||||||
faqs: Faq[]; | ||||||
|
||||||
filteredFaqs: Faq[]; | ||||||
|
@@ -51,13 +55,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); | ||||||
}); | ||||||
|
@@ -78,6 +97,7 @@ export class CourseFaqComponent implements OnInit, OnDestroy { | |||||
next: (res: Faq[]) => { | ||||||
this.faqs = res; | ||||||
this.applyFilters(); | ||||||
this.sortFaqs(); | ||||||
}, | ||||||
error: (res: HttpErrorResponse) => onError(this.alertService, res), | ||||||
}); | ||||||
|
@@ -113,4 +133,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) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Access The Apply this diff to fix the usage: -const faqElement = this.faqElements().find((faq) => faq.nativeElement.id === 'faq-' + String(faqId));
+const faqElement = this.faqElements.toArray().find((faq) => faq.nativeElement.id === 'faq-' + String(faqId)); 📝 Committable suggestion
Suggested change
|
||||||
this.renderer.selectRootElement(faqElement.nativeElement, true).scrollIntoView({ behavior: 'smooth', block: 'start' }); | ||||||
} | ||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -68,6 +68,8 @@ import { CourseConversationsComponent } from 'app/overview/course-conversations/ | |
import { sortCourses } from 'app/shared/util/course.util'; | ||
import { CourseUnenrollmentModalComponent } from './course-unenrollment-modal.component'; | ||
import { LtiService } from 'app/shared/service/lti.service'; | ||
import { Faq, FaqState } from 'app/entities/faq.model'; | ||
import { FaqService } from 'app/faq/faq.service'; | ||
import { CourseSidebarService } from 'app/overview/course-sidebar.service'; | ||
|
||
interface CourseActionItem { | ||
|
@@ -191,6 +193,7 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit | |
readonly isMessagingEnabled = isMessagingEnabled; | ||
readonly isCommunicationEnabled = isCommunicationEnabled; | ||
|
||
private faqService = inject(FaqService); | ||
private courseSidebarService: CourseSidebarService = inject(CourseSidebarService); | ||
|
||
constructor( | ||
|
@@ -717,6 +720,7 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit | |
map((res: HttpResponse<Course>) => { | ||
if (res.body) { | ||
this.course = res.body; | ||
this.setFaqs(this.course); | ||
} | ||
|
||
if (refresh) { | ||
|
@@ -755,6 +759,23 @@ export class CourseOverviewComponent implements OnInit, OnDestroy, AfterViewInit | |
return observable; | ||
} | ||
|
||
/** | ||
* set course property before using metis service | ||
* @param {Course} course in which the metis service is used | ||
*/ | ||
setFaqs(course: Course | undefined): void { | ||
if (course) { | ||
this.faqService | ||
.findAllByCourseIdAndState(this.courseId, FaqState.ACCEPTED) | ||
.pipe(map((res: HttpResponse<Faq[]>) => res.body)) | ||
.subscribe({ | ||
next: (res: Faq[]) => { | ||
course.faqs = res; | ||
}, | ||
}); | ||
} | ||
} | ||
Comment on lines
+762
to
+777
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add error handling and subscription management. The
Here's the suggested implementation: /**
- * set course property before using metis service
+ * Fetches and sets the accepted FAQs for the given course
* @param {Course} course in which the metis service is used
*/
setFaqs(course: Course | undefined): void {
if (course) {
this.faqService
.findAllByCourseIdAndState(this.courseId, FaqState.ACCEPTED)
.pipe(
map((res: HttpResponse<Faq[]>) => res.body),
+ takeUntil(this.ngUnsubscribe)
)
.subscribe({
next: (res: Faq[]) => {
course.faqs = res;
},
+ error: (error) => {
+ this.alertService.addAlert({
+ type: AlertType.DANGER,
+ message: 'artemisApp.faq.error.load',
+ });
+ }
});
}
}
|
||
|
||
ngOnDestroy() { | ||
if (this.teamAssignmentUpdateListener) { | ||
this.teamAssignmentUpdateListener.unsubscribe(); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 { | |
protected readonly faBan = faBan; | ||
protected readonly faAt = faAt; | ||
protected readonly faHashtag = faHashtag; | ||
protected readonly faQuestion = faQuestion; | ||
|
||
protected readonly ReferenceType = ReferenceType; | ||
|
||
|
@@ -99,6 +101,8 @@ export class PostingContentPartComponent { | |
return faFileUpload; | ||
case ReferenceType.SLIDE: | ||
return faFile; | ||
case ReferenceType.FAQ: | ||
return faQuestion; | ||
Comment on lines
+104
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Consider extracting icon mappings to improve maintainability. While the FAQ case is correctly implemented, the Here's a suggested refactoring: private static readonly REFERENCE_TYPE_ICONS: Record<ReferenceType, IconProp> = {
[ReferenceType.POST]: faMessage,
[ReferenceType.LECTURE]: faChalkboardUser,
[ReferenceType.PROGRAMMING]: faKeyboard,
[ReferenceType.MODELING]: faProjectDiagram,
[ReferenceType.QUIZ]: faCheckDouble,
[ReferenceType.TEXT]: faFont,
[ReferenceType.FILE_UPLOAD]: faFileUpload,
[ReferenceType.SLIDE]: faFile,
[ReferenceType.FAQ]: faQuestion
};
referenceIcon(reference: ReferenceType): IconProp {
return PostingContentPartComponent.REFERENCE_TYPE_ICONS[reference] ?? faPaperclip;
} This approach:
|
||
default: | ||
return faPaperclip; | ||
} | ||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -117,6 +117,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; | ||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+120
to
+125
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add null checks and improve string parsing robustness. The FAQ reference parsing logic could be more resilient to malformed input. Consider these improvements: } 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;
+ const closingBracketIndex = this.content.indexOf(']', patternMatch.startIndex);
+ const coursePathIndex = this.content.indexOf('(/courses', patternMatch.startIndex);
+ const faqIdIndex = this.content.indexOf('?faqId', patternMatch.startIndex);
+ const equalsIndex = this.content.indexOf('=', faqIdIndex);
+ const closingParenIndex = this.content.indexOf(')', equalsIndex);
+
+ if (closingBracketIndex === -1 || coursePathIndex === -1 || faqIdIndex === -1 ||
+ equalsIndex === -1 || closingParenIndex === -1) {
+ console.warn('Malformed FAQ reference detected');
+ return;
+ }
+
+ referenceStr = this.content.substring(closingBracketIndex + 1, coursePathIndex);
+ linkToReference = [this.content.substring(coursePathIndex + 1, faqIdIndex)];
+ queryParams = { faqId: this.content.substring(equalsIndex + 1, closingParenIndex) } as Params; 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||
} 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 | ||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -206,9 +212,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 | ||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Consider improving regex maintainability and validation. The regex pattern could benefit from these improvements:
private static readonly PATTERNS = {
POST: '#\\d+',
FAQ: '\\[faq].*?\\[\\/faq]',
// ... other patterns
};
private static readonly COMBINED_PATTERN = new RegExp(
Object.entries(PATTERNS)
.map(([key, pattern]) => `(?<${key}>${pattern})`)
.join('|'),
'g'
);
FAQ: '\\[faq]\\s*([^\\[\\]]+?)\\s*\\(\\s*\\/courses\\/[^\\s]+\\?faqId=\\d+\\s*\\)\\s*\\[\\/faq]' Also applies to: 218-218 |
||||||||||||||||||||||||||||||||||||||||||||||
// 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[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct the casing of
ViewChildren
in the import statementThe import statement incorrectly uses
viewChildren
instead ofViewChildren
. In Angular, decorators and class names should follow PascalCase naming convention.Apply this diff to fix the import:
📝 Committable suggestion