Skip to content

Commit

Permalink
Communication: Add FAQ search bar (#9423)
Browse files Browse the repository at this point in the history
  • Loading branch information
cremertim authored Oct 12, 2024
1 parent 4dacc21 commit f977aca
Show file tree
Hide file tree
Showing 19 changed files with 141 additions and 35 deletions.
5 changes: 4 additions & 1 deletion src/main/webapp/app/faq/faq.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ <h2 id="page-heading">
<span jhiTranslate="artemisApp.faq.home.title"></span>
</h2>
</div>
<div class="d-flex flex-row-reverse bd-highlight mb-3">
<div class="d-flex justify-content-between bd-highlight mb-3 gap-2">
<div class="flex-grow-1">
<jhi-search-filter (newSearchEvent)="setSearchValue($event)" />
</div>
<div aria-label="Button group with nested dropdown" class="btn-group" role="group">
<div class="me-2" aria-label="Filter Dropdown" ngbDropdown>
<button
Expand Down
29 changes: 25 additions & 4 deletions src/main/webapp/app/faq/faq.component.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { Faq } from 'app/entities/faq.model';
import { faEdit, faFilter, faPencilAlt, faPlus, faSort, faTrash } from '@fortawesome/free-solid-svg-icons';
import { Subject } from 'rxjs';
import { map } from 'rxjs/operators';
import { BehaviorSubject, Subject } from 'rxjs';
import { debounceTime, map } from 'rxjs/operators';
import { AlertService } from 'app/core/util/alert.service';
import { ActivatedRoute } from '@angular/router';
import { FaqService } from 'app/faq/faq.service';
Expand All @@ -15,13 +15,14 @@ import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-catego
import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module';
import { ArtemisSharedModule } from 'app/shared/shared.module';
import { ArtemisMarkdownModule } from 'app/shared/markdown.module';
import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component';

@Component({
selector: 'jhi-faq',
templateUrl: './faq.component.html',
styleUrls: [],
standalone: true,
imports: [ArtemisSharedModule, CustomExerciseCategoryBadgeComponent, ArtemisSharedComponentModule, ArtemisMarkdownModule],
imports: [ArtemisSharedModule, CustomExerciseCategoryBadgeComponent, ArtemisSharedComponentModule, ArtemisMarkdownModule, SearchFilterComponent],
})
export class FaqComponent implements OnInit, OnDestroy {
faqs: Faq[];
Expand All @@ -34,6 +35,7 @@ export class FaqComponent implements OnInit, OnDestroy {
dialogError$ = this.dialogErrorSource.asObservable();

activeFilters = new Set<string>();
searchInput = new BehaviorSubject<string>('');
predicate: string;
ascending: boolean;

Expand All @@ -59,10 +61,14 @@ export class FaqComponent implements OnInit, OnDestroy {
this.courseId = Number(this.route.snapshot.paramMap.get('courseId'));
this.loadAll();
this.loadCourseFaqCategories(this.courseId);
this.searchInput.pipe(debounceTime(300)).subscribe((searchTerm: string) => {
this.refreshFaqList(searchTerm);
});
}

ngOnDestroy(): void {
this.dialogErrorSource.complete();
this.searchInput.complete();
}

deleteFaq(courseId: number, faqId: number) {
Expand All @@ -80,7 +86,7 @@ export class FaqComponent implements OnInit, OnDestroy {

toggleFilters(category: string) {
this.activeFilters = this.faqService.toggleFilter(category, this.activeFilters);
this.applyFilters();
this.refreshFaqList(this.searchInput.getValue());
}

private applyFilters(): void {
Expand Down Expand Up @@ -121,4 +127,19 @@ export class FaqComponent implements OnInit, OnDestroy {
});
this.applyFilters();
}

private applySearch(searchTerm: string) {
this.filteredFaqs = this.filteredFaqs.filter((faq) => {
return this.faqService.hasSearchTokens(faq, searchTerm);
});
}

setSearchValue(searchValue: string) {
this.searchInput.next(searchValue);
}

refreshFaqList(searchTerm: string) {
this.applyFilters();
this.applySearch(searchTerm);
}
}
9 changes: 9 additions & 0 deletions src/main/webapp/app/faq/faq.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,13 @@ export class FaqService {
return categories.some((category) => filteredCategory.has(category!));
}
}

hasSearchTokens(faq: Faq, searchTerm: string): boolean {
if (searchTerm === '') {
return true;
}
const tokens = searchTerm.toLowerCase().split(' ');
const faqText = `${faq.questionTitle ?? ''} ${faq.questionAnswer ?? ''}`.toLowerCase();
return tokens.every((token) => faqText.includes(token));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<div>
<div class="input-group mb-2 rounded-3 p-2 me-2 module-bg" style="display: flex; justify-content: end">
<div class="me-2" aria-label="Filter Dropdown" ngbDropdown>
<div class="input-group mb-2 rounded-3 p-2 me-2 module-bg d-flex justify-content-between">
<jhi-search-filter class="flex-grow-1" (newSearchEvent)="setSearchValue($event)" />
<div class="ms-2 me-2" aria-label="Filter Dropdown" ngbDropdown>
<button class="btn" [ngClass]="{ 'btn-secondary': activeFilters.size === 0, 'btn-success': activeFilters.size > 0 }" ngbDropdownToggle id="filter-dropdown-button">
<fa-icon [icon]="faFilter" />
<span class="d-s-none d-md-inline" jhiTranslate="artemisApp.courseOverview.exerciseList.filter" [translateValues]="{ num: activeFilters.size }"></span>
Expand Down
32 changes: 26 additions & 6 deletions src/main/webapp/app/overview/course-faq/course-faq.component.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Component, OnDestroy, OnInit, ViewEncapsulation, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs/operators';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, map } 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';
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';
Expand All @@ -16,14 +15,15 @@ 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';
import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component';

@Component({
selector: 'jhi-course-faq',
templateUrl: './course-faq.component.html',
styleUrls: ['../course-overview.scss', 'course-faq.component.scss'],
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [ArtemisSharedComponentModule, ArtemisSharedModule, CourseFaqAccordionComponent, CustomExerciseCategoryBadgeComponent],
imports: [ArtemisSharedComponentModule, ArtemisSharedModule, CourseFaqAccordionComponent, CustomExerciseCategoryBadgeComponent, SearchFilterComponent],
})
export class CourseFaqComponent implements OnInit, OnDestroy {
private ngUnsubscribe = new Subject<void>();
Expand All @@ -36,10 +36,11 @@ export class CourseFaqComponent implements OnInit, OnDestroy {
existingCategories: FaqCategory[];
activeFilters = new Set<string>();

sidebarData: SidebarData;
hasCategories = false;
isCollapsed = false;

searchInput = new BehaviorSubject<string>('');

readonly ButtonType = ButtonType;

// Icons
Expand All @@ -56,6 +57,9 @@ export class CourseFaqComponent implements OnInit, OnDestroy {
this.loadFaqs();
this.loadCourseExerciseCategories(this.courseId);
});
this.searchInput.pipe(debounceTime(300)).subscribe((searchTerm: string) => {
this.refreshFaqList(searchTerm);
});
}

private loadCourseExerciseCategories(courseId: number) {
Expand All @@ -82,14 +86,30 @@ export class CourseFaqComponent implements OnInit, OnDestroy {
this.ngUnsubscribe.next();
this.ngUnsubscribe.complete();
this.parentParamSubscription?.unsubscribe();
this.searchInput.complete();
}

toggleFilters(category: string) {
this.activeFilters = this.faqService.toggleFilter(category, this.activeFilters);
this.applyFilters();
this.refreshFaqList(this.searchInput.getValue());
}

private applyFilters(): void {
this.filteredFaqs = this.faqService.applyFilters(this.activeFilters, this.faqs);
}

private applySearch(searchTerm: string) {
this.filteredFaqs = this.filteredFaqs.filter((faq) => {
return this.faqService.hasSearchTokens(faq, searchTerm);
});
}

setSearchValue(searchValue: string) {
this.searchInput.next(searchValue);
}

refreshFaqList(searchTerm: string) {
this.applyFilters();
this.applySearch(searchTerm);
}
}
2 changes: 2 additions & 0 deletions src/main/webapp/app/overview/courses.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ArtemisCourseExerciseRowModule } from 'app/overview/course-exercises/co
import { NgxChartsModule, PieChartModule } from '@swimlane/ngx-charts';
import { CourseUnenrollmentModalComponent } from 'app/overview/course-unenrollment-modal.component';
import { ArtemisSidebarModule } from 'app/shared/sidebar/sidebar.module';
import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component';

@NgModule({
imports: [
Expand All @@ -36,6 +37,7 @@ import { ArtemisSidebarModule } from 'app/shared/sidebar/sidebar.module';
NgxChartsModule,
PieChartModule,
ArtemisSidebarModule,
SearchFilterComponent,
],
declarations: [
CoursesComponent,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { faMagnifyingGlass, faTimes } from '@fortawesome/free-solid-svg-icons';
import { ArtemisSharedModule } from 'app/shared/shared.module';

@Component({
selector: 'jhi-search-filter',
templateUrl: './search-filter.component.html',
styleUrls: ['./search-filter.component.scss'],
standalone: true,
imports: [ArtemisSharedModule],
})
export class SearchFilterComponent {
faMagnifyingGlass = faMagnifyingGlass;
Expand Down
3 changes: 0 additions & 3 deletions src/main/webapp/app/shared/shared.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import { StickyPopoverDirective } from 'app/shared/sticky-popover/sticky-popover
import { ConfirmEntityNameComponent } from 'app/shared/confirm-entity-name/confirm-entity-name.component';
import { DetailOverviewNavigationBarComponent } from 'app/shared/detail-overview-navigation-bar/detail-overview-navigation-bar.component';
import { ScienceDirective } from 'app/shared/science/science.directive';
import { SearchFilterComponent } from './search-filter/search-filter.component';

@NgModule({
imports: [ArtemisSharedLibsModule, ArtemisSharedCommonModule, ArtemisSharedPipesModule, RouterModule],
Expand Down Expand Up @@ -56,7 +55,6 @@ import { SearchFilterComponent } from './search-filter/search-filter.component';
AssessmentWarningComponent,
StickyPopoverDirective,
ScienceDirective,
SearchFilterComponent,
],
exports: [
ArtemisSharedLibsModule,
Expand Down Expand Up @@ -87,7 +85,6 @@ import { SearchFilterComponent } from './search-filter/search-filter.component';
CompetencySelectionComponent,
StickyPopoverDirective,
ScienceDirective,
SearchFilterComponent,
],
})
export class ArtemisSharedModule {}
2 changes: 2 additions & 0 deletions src/main/webapp/app/shared/sidebar/sidebar.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { SidebarCardDirective } from 'app/shared/sidebar/sidebar-card.directive'
import { ConversationOptionsComponent } from 'app/shared/sidebar/conversation-options/conversation-options.component';
import { AccordionAddOptionsComponent } from 'app/shared/sidebar/accordion-add-options/accordion-add-options.component';
import { ArtemisExamSharedModule } from 'app/exam/shared/exam-shared.module';
import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component';

@NgModule({
imports: [
Expand All @@ -28,6 +29,7 @@ import { ArtemisExamSharedModule } from 'app/exam/shared/exam-shared.module';
SubmissionResultStatusModule,
SidebarCardDirective,
ArtemisExamSharedModule,
SearchFilterComponent,
],
declarations: [
SidebarAccordionComponent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ describe('CourseExercisesComponent', () => {
declarations: [
CourseExercisesComponent,
SidebarComponent,
SearchFilterComponent,
MockDirective(OrionFilterDirective),
MockComponent(CourseExerciseRowComponent),
MockComponent(SidePanelComponent),
Expand All @@ -60,6 +59,7 @@ describe('CourseExercisesComponent', () => {
MockDirective(DeleteButtonDirective),
MockTranslateValuesDirective,
MockPipe(SearchFilterPipe),
MockComponent(SearchFilterComponent),
],
providers: [
{ provide: SessionStorageService, useClass: MockSyncStorage },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { facSaveSuccess, facSaveWarning } from 'src/main/webapp/content/icons/ic
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ExamLiveEventsButtonComponent } from 'app/exam/participate/events/exam-live-events-button.component';
import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module';
import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component';

describe('ExamNavigationSidebarComponent', () => {
let fixture: ComponentFixture<ExamNavigationSidebarComponent>;
Expand All @@ -35,7 +36,7 @@ describe('ExamNavigationSidebarComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ArtemisTestModule, TranslateTestingModule, MockModule(NgbTooltipModule), MockModule(ArtemisSharedCommonModule)],
declarations: [ExamNavigationSidebarComponent, MockComponent(ExamTimerComponent), MockComponent(ExamLiveEventsButtonComponent)],
declarations: [ExamNavigationSidebarComponent, MockComponent(ExamTimerComponent), MockComponent(ExamLiveEventsButtonComponent), MockComponent(SearchFilterComponent)],
providers: [
ExamParticipationService,
{ provide: ExamExerciseUpdateService, useValue: mockExamExerciseUpdateService },
Expand Down
26 changes: 20 additions & 6 deletions src/test/javascript/spec/component/faq/faq.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { ComponentFixture, TestBed, fakeAsync, flush } from '@angular/core/testing';
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';
Expand Down Expand Up @@ -93,6 +93,9 @@ describe('FaqComponent', () => {
applyFilters: () => {
return [faq2, faq3];
},
hasSearchTokens: () => {
return true;
},
}),
],
})
Expand Down Expand Up @@ -158,20 +161,31 @@ describe('FaqComponent', () => {
expect(faqComponent.filteredFaqs).toEqual([faq2, faq3]);
});

it('should catch error if no categories are found', fakeAsync(() => {
it('should catch error if no categories are found', () => {
alertServiceStub = jest.spyOn(alertService, 'error');
const error = { status: 404 };
jest.spyOn(faqService, 'findAllCategoriesByCourseId').mockReturnValue(throwError(() => new HttpErrorResponse(error)));
faqComponentFixture.detectChanges();
expect(alertServiceStub).toHaveBeenCalledOnce();
flush();
}));
});

it('should search through already filtered array', () => {
const searchSpy = jest.spyOn(faqService, 'hasSearchTokens');
const applyFilterSpy = jest.spyOn(faqService, 'applyFilters');
faqComponent.setSearchValue('questionTitle');
faqComponent.refreshFaqList(faqComponent.searchInput.getValue());
expect(applyFilterSpy).toHaveBeenCalledOnce();
expect(searchSpy).toHaveBeenCalledTimes(2);
expect(searchSpy).toHaveBeenCalledWith(faq2, 'questionTitle');
expect(searchSpy).toHaveBeenCalledWith(faq3, 'questionTitle');
expect(faqComponent.filteredFaqs).toHaveLength(2);
expect(faqComponent.filteredFaqs).not.toContain(faq1);
expect(faqComponent.filteredFaqs).toEqual([faq2, faq3]);
});

it('should call sortService when sortRows is called', () => {
jest.spyOn(sortService, 'sortByProperty').mockReturnValue([]);

faqComponent.sortRows();

expect(sortService.sortByProperty).toHaveBeenCalledOnce();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { CourseExamsComponent } from 'app/overview/course-exams/course-exams.com
import { Exam } from 'app/entities/exam/exam.model';
import { ArtemisTestModule } from '../../../test.module';
import dayjs from 'dayjs/esm';
import { MockDirective, MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { MockComponent, MockDirective, MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { Observable, of } from 'rxjs';
import { ArtemisServerDateService } from 'app/shared/server-date.service';
import { ExamParticipationService } from 'app/exam/participate/exam-participation.service';
Expand Down Expand Up @@ -101,7 +101,7 @@ describe('CourseExamsComponent', () => {

TestBed.configureTestingModule({
imports: [ArtemisTestModule, RouterModule.forRoot([]), MockModule(FormsModule), MockModule(ReactiveFormsModule), MockDirective(TranslateDirective)],
declarations: [CourseExamsComponent, SidebarComponent, SearchFilterComponent, MockPipe(ArtemisTranslatePipe), MockPipe(SearchFilterPipe)],
declarations: [CourseExamsComponent, SidebarComponent, MockComponent(SearchFilterComponent), MockPipe(ArtemisTranslatePipe), MockPipe(SearchFilterPipe)],
providers: [
{ provide: Router, useValue: router },
{
Expand Down
Loading

0 comments on commit f977aca

Please sign in to comment.