From 529891011f2e17f82ede83533d16bcf2bd0d1220 Mon Sep 17 00:00:00 2001 From: Florian Glombik <63976129+florian-glombik@users.noreply.github.com> Date: Thu, 29 Aug 2024 17:45:44 +0200 Subject: [PATCH] General: Add filter for exercises (#8858) --- .../app/entities/exercise-category.model.ts | 26 +- .../shared/exercise/exercise.service.ts | 9 +- .../participation/participation.utils.ts | 6 +- .../course-exercises.component.html | 2 +- .../category-selector.component.ts | 11 +- .../reset-repo-button.component.ts | 3 +- .../date-time-picker.component.ts | 13 +- ...tom-exercise-category-badge.component.html | 10 + ...tom-exercise-category-badge.component.scss | 18 + ...ustom-exercise-category-badge.component.ts | 23 ++ .../exercise-categories.component.html | 4 +- .../exercise-categories.module.ts | 3 +- .../exercise-filter-modal.component.html | 145 ++++++++ .../exercise-filter-modal.component.scss | 20 ++ .../exercise-filter-modal.component.ts | 231 ++++++++++++ .../exercise-filter-modal.helper.ts | 84 +++++ .../range-slider/range-slider.component.html | 32 ++ .../range-slider/range-slider.component.scss | 65 ++++ .../range-slider/range-slider.component.ts | 141 ++++++++ .../sidebar-accordion.component.ts | 8 +- .../app/shared/sidebar/sidebar.component.html | 19 +- .../app/shared/sidebar/sidebar.component.ts | 67 +++- .../app/shared/sidebar/sidebar.helper.ts | 269 ++++++++++++++ .../statistics-graph/statistics.service.ts | 6 +- src/main/webapp/app/shared/util/utils.ts | 15 + src/main/webapp/app/types/exercise-filter.ts | 43 +++ src/main/webapp/i18n/de/exercise.json | 1 + .../webapp/i18n/de/student-dashboard.json | 14 +- src/main/webapp/i18n/en/exercise.json | 1 + .../webapp/i18n/en/student-dashboard.json | 14 +- .../course/course-exercises.component.spec.ts | 3 +- .../course/exercise-filter.model.spec.ts | 6 +- ...ercise-page-with-details.component.spec.ts | 3 +- ...e-upload-exercise-update.component.spec.ts | 3 +- ...modeling-exercise-update.component.spec.ts | 9 +- .../course-exams.component.spec.ts | 7 +- ...gramming-exercise-update.component.spec.ts | 2 +- .../quiz-exercise-update.component.spec.ts | 9 +- .../component/range-slider.component.spec.ts | 69 ++++ .../exercise-filter-modal.component.spec.ts | 259 ++++++++++++++ .../exercise-filter-modal.helper.spec.ts | 333 ++++++++++++++++++ .../sidebar-accordion.component.spec.ts | 18 +- .../shared/sidebar/sidebar.component.spec.ts | 160 ++++++++- .../shared/sidebar/sidebar.helper.spec.ts | 329 +++++++++++++++++ ...tics-average-score-graph.component.spec.ts | 11 +- .../text-exercise-update.component.spec.ts | 3 +- .../course-tutorial-groups.component.spec.ts | 5 +- .../entities/exercise-category.model.spec.ts | 49 +++ .../service/modeling-exercise.service.spec.ts | 4 +- .../javascript/spec/util/shared/utils.spec.ts | 23 ++ 50 files changed, 2518 insertions(+), 90 deletions(-) create mode 100644 src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.html create mode 100644 src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.scss create mode 100644 src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.ts create mode 100644 src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.html create mode 100644 src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.scss create mode 100644 src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.ts create mode 100644 src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.helper.ts create mode 100644 src/main/webapp/app/shared/range-slider/range-slider.component.html create mode 100644 src/main/webapp/app/shared/range-slider/range-slider.component.scss create mode 100644 src/main/webapp/app/shared/range-slider/range-slider.component.ts create mode 100644 src/main/webapp/app/shared/sidebar/sidebar.helper.ts create mode 100644 src/main/webapp/app/types/exercise-filter.ts create mode 100644 src/test/javascript/spec/component/range-slider.component.spec.ts create mode 100644 src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.component.spec.ts create mode 100644 src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.helper.spec.ts create mode 100644 src/test/javascript/spec/component/shared/sidebar/sidebar.helper.spec.ts create mode 100644 src/test/javascript/spec/entities/exercise-category.model.spec.ts diff --git a/src/main/webapp/app/entities/exercise-category.model.ts b/src/main/webapp/app/entities/exercise-category.model.ts index bc134d0b7f29..68f09c914d2d 100644 --- a/src/main/webapp/app/entities/exercise-category.model.ts +++ b/src/main/webapp/app/entities/exercise-category.model.ts @@ -1,6 +1,30 @@ export class ExerciseCategory { public color?: string; + + // TODO should be renamed to "name" -> accessing variable via "category.name" instead of "category.category" - requires database migration (stored as json in database, see the table "exercise_categories") public category?: string; - constructor() {} + constructor(category: string | undefined, color: string | undefined) { + this.color = color; + this.category = category; + } + + equals(otherExerciseCategory: ExerciseCategory): boolean { + return this.color === otherExerciseCategory.color && this.category === otherExerciseCategory.category; + } + + /** + * @param otherExerciseCategory + * @returns the alphanumerical order of the two exercise categories based on their display text + */ + compare(otherExerciseCategory: ExerciseCategory): number { + if (this.category === otherExerciseCategory.category) { + return 0; + } + + const displayText = this.category?.toLowerCase() ?? ''; + const otherCategoryDisplayText = otherExerciseCategory.category?.toLowerCase() ?? ''; + + return displayText < otherCategoryDisplayText ? -1 : 1; + } } diff --git a/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts b/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts index dffb26604cc7..50c2227751e3 100644 --- a/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts +++ b/src/main/webapp/app/exercises/shared/exercise/exercise.service.ts @@ -347,7 +347,7 @@ export class ExerciseService { * @param exercise the exercise */ static stringifyExerciseCategories(exercise: Exercise) { - return exercise.categories?.map((category) => JSON.stringify(category) as ExerciseCategory); + return exercise.categories?.map((category) => JSON.stringify(category) as unknown as ExerciseCategory); } /** @@ -362,12 +362,15 @@ export class ExerciseService { } /** - * Parses the exercise categories JSON string into ExerciseCategory objects. + * Parses the exercise categories JSON string into {@link ExerciseCategory} objects. * @param exercise - the exercise */ static parseExerciseCategories(exercise?: Exercise) { if (exercise?.categories) { - exercise.categories = exercise.categories.map((category) => JSON.parse(category as string) as ExerciseCategory); + exercise.categories = exercise.categories.map((category) => { + const categoryObj = JSON.parse(category as unknown as string); + return new ExerciseCategory(categoryObj.category, categoryObj.color); + }); } } diff --git a/src/main/webapp/app/exercises/shared/participation/participation.utils.ts b/src/main/webapp/app/exercises/shared/participation/participation.utils.ts index 7c7a99d3830c..5fc349f22b27 100644 --- a/src/main/webapp/app/exercises/shared/participation/participation.utils.ts +++ b/src/main/webapp/app/exercises/shared/participation/participation.utils.ts @@ -102,7 +102,11 @@ export const isParticipationInDueTime = (participation: Participation, exercise: * @param participation * @param showUngradedResults */ -export function getLatestResultOfStudentParticipation(participation: StudentParticipation, showUngradedResults: boolean): Result | undefined { +export function getLatestResultOfStudentParticipation(participation: StudentParticipation | undefined, showUngradedResults: boolean): Result | undefined { + if (!participation) { + return undefined; + } + // Sort participation results by completionDate desc. if (participation.results) { participation.results = _orderBy(participation.results, 'completionDate', 'desc'); diff --git a/src/main/webapp/app/overview/course-exercises/course-exercises.component.html b/src/main/webapp/app/overview/course-exercises/course-exercises.component.html index 2de6ec14a991..6be118bf7475 100644 --- a/src/main/webapp/app/overview/course-exercises/course-exercises.component.html +++ b/src/main/webapp/app/overview/course-exercises/course-exercises.component.html @@ -1,7 +1,7 @@
@if (course) {
- +
@if (exerciseSelected) { diff --git a/src/main/webapp/app/shared/category-selector/category-selector.component.ts b/src/main/webapp/app/shared/category-selector/category-selector.component.ts index 03b24cac6ca1..d899a7b034b3 100644 --- a/src/main/webapp/app/shared/category-selector/category-selector.component.ts +++ b/src/main/webapp/app/shared/category-selector/category-selector.component.ts @@ -1,13 +1,12 @@ import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, ViewChild, ViewEncapsulation } from '@angular/core'; import { ColorSelectorComponent } from 'app/shared/color-selector/color-selector.component'; import { ExerciseCategory } from 'app/entities/exercise-category.model'; -import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger } from '@angular/material/autocomplete'; import { COMMA, ENTER, TAB } from '@angular/cdk/keycodes'; import { FormControl } from '@angular/forms'; import { MatChipInputEvent } from '@angular/material/chips'; import { Observable, map, startWith } from 'rxjs'; import { faTimes } from '@fortawesome/free-solid-svg-icons'; -import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; const DEFAULT_COLORS = ['#6ae8ac', '#9dca53', '#94a11c', '#691b0b', '#ad5658', '#1b97ca', '#0d3cc2', '#0ab84f']; @@ -44,8 +43,7 @@ export class CategorySelectorComponent implements OnChanges { separatorKeysCodes = [ENTER, COMMA, TAB]; categoryCtrl = new FormControl(undefined); - // Icons - faTimes = faTimes; + readonly faTimes = faTimes; ngOnChanges() { this.uniqueCategoriesForAutocomplete = this.categoryCtrl.valueChanges.pipe( @@ -134,10 +132,7 @@ export class CategorySelectorComponent implements OnChanges { } private createCategory(categoryString: string): ExerciseCategory { - const category = new ExerciseCategory(); - category.category = categoryString; - category.color = this.chooseRandomColor(); - return category; + return new ExerciseCategory(categoryString, this.chooseRandomColor()); } private chooseRandomColor(): string { diff --git a/src/main/webapp/app/shared/components/reset-repo-button/reset-repo-button.component.ts b/src/main/webapp/app/shared/components/reset-repo-button/reset-repo-button.component.ts index c939dad8cb00..b196fd1d3df3 100644 --- a/src/main/webapp/app/shared/components/reset-repo-button/reset-repo-button.component.ts +++ b/src/main/webapp/app/shared/components/reset-repo-button/reset-repo-button.component.ts @@ -29,8 +29,7 @@ export class ResetRepoButtonComponent implements OnInit { beforeIndividualDueDate: boolean; - // Icons - faBackward = faBackward; + readonly faBackward = faBackward; constructor( private participationService: ParticipationService, diff --git a/src/main/webapp/app/shared/date-time-picker/date-time-picker.component.ts b/src/main/webapp/app/shared/date-time-picker/date-time-picker.component.ts index 320817cfedc0..58196f94f02d 100644 --- a/src/main/webapp/app/shared/date-time-picker/date-time-picker.component.ts +++ b/src/main/webapp/app/shared/date-time-picker/date-time-picker.component.ts @@ -24,18 +24,17 @@ export class FormDateTimePickerComponent implements ControlValueAccessor { @Input() error: boolean; @Input() warning: boolean; @Input() requiredField: boolean = false; - @Input() startAt?: dayjs.Dayjs; // Default selected date. By default this sets it to the current time without seconds or milliseconds; + @Input() startAt?: dayjs.Dayjs; // Default selected date. By default, this sets it to the current time without seconds or milliseconds; @Input() min?: dayjs.Dayjs; // Dates before this date are not selectable. @Input() max?: dayjs.Dayjs; // Dates after this date are not selectable. @Input() shouldDisplayTimeZoneWarning = true; // Displays a warning that the current time zone might differ from the participants'. @Output() valueChange = new EventEmitter(); - // Icons - faCalendarAlt = faCalendarAlt; - faGlobe = faGlobe; - faClock = faClock; - faQuestionCircle = faQuestionCircle; - faCircleXmark = faCircleXmark; + readonly faCalendarAlt = faCalendarAlt; + readonly faGlobe = faGlobe; + readonly faClock = faClock; + readonly faQuestionCircle = faQuestionCircle; + readonly faCircleXmark = faCircleXmark; readonly faTriangleExclamation = faTriangleExclamation; private onChange?: (val?: dayjs.Dayjs) => void; diff --git a/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.html b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.html new file mode 100644 index 000000000000..eec73d5844e4 --- /dev/null +++ b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.html @@ -0,0 +1,10 @@ +

+
+ {{ category.category }} + @if (displayRemoveButton) { + + } +
+

diff --git a/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.scss b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.scss new file mode 100644 index 000000000000..43a2d70cca34 --- /dev/null +++ b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.scss @@ -0,0 +1,18 @@ +.remove-button { + color: var(--white); + background: transparent !important; + border: none !important; + + &:hover { + opacity: 0.8; + } +} + +/** category options should be consistent with the type CategoryFontSize */ +.category-small { + font-size: 0.85rem; +} + +.category-default { + font-size: 1rem; +} diff --git a/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.ts b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.ts new file mode 100644 index 000000000000..8ba41f96ba8b --- /dev/null +++ b/src/main/webapp/app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core'; +import type { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { CommonModule } from '@angular/common'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; + +type CategoryFontSize = 'default' | 'small'; + +@Component({ + selector: 'jhi-custom-exercise-category-badge', + templateUrl: './custom-exercise-category-badge.component.html', + styleUrls: ['custom-exercise-category-badge.component.scss'], + standalone: true, + imports: [CommonModule, FontAwesomeModule], +}) +export class CustomExerciseCategoryBadgeComponent { + protected readonly faTimes = faTimes; + + @Input({ required: true }) category: ExerciseCategory; + @Input() displayRemoveButton: boolean = false; + @Input() onClick: () => void = () => {}; + @Input() fontSize: CategoryFontSize = 'default'; +} diff --git a/src/main/webapp/app/shared/exercise-categories/exercise-categories.component.html b/src/main/webapp/app/shared/exercise-categories/exercise-categories.component.html index dd757a2d5b36..f811a3f4c4e7 100644 --- a/src/main/webapp/app/shared/exercise-categories/exercise-categories.component.html +++ b/src/main/webapp/app/shared/exercise-categories/exercise-categories.component.html @@ -11,9 +11,7 @@

} @for (category of exercise.categories; track category) { -

- {{ category.category }} -

+ } @if (exercise.difficulty && showTags.difficulty) {

diff --git a/src/main/webapp/app/shared/exercise-categories/exercise-categories.module.ts b/src/main/webapp/app/shared/exercise-categories/exercise-categories.module.ts index f56611d32021..f0f8877ffdec 100644 --- a/src/main/webapp/app/shared/exercise-categories/exercise-categories.module.ts +++ b/src/main/webapp/app/shared/exercise-categories/exercise-categories.module.ts @@ -3,9 +3,10 @@ import { RouterModule } from '@angular/router'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ExerciseCategoriesComponent } from 'app/shared/exercise-categories/exercise-categories.component'; +import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; @NgModule({ - imports: [ArtemisSharedModule, RouterModule, ArtemisSharedComponentModule], + imports: [ArtemisSharedModule, RouterModule, ArtemisSharedComponentModule, CustomExerciseCategoryBadgeComponent], declarations: [ExerciseCategoriesComponent], exports: [ExerciseCategoriesComponent], }) diff --git a/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.html b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.html new file mode 100644 index 000000000000..1a87237a4d46 --- /dev/null +++ b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.html @@ -0,0 +1,145 @@ +
+ + + +
diff --git a/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.scss b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.scss new file mode 100644 index 000000000000..c2db4383039b --- /dev/null +++ b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.scss @@ -0,0 +1,20 @@ +/* +ensures that the category dropdown selection does not overflow the screen to the bottom +(instead a scrollbar will be displayed) + */ +:host ::ng-deep ngb-typeahead-window.dropdown-menu { + max-height: 25rem; + overflow-y: auto; +} + +/* align the first checkbox to the left */ +.no-left-margin-padding:first-child { + margin-left: 0; + padding-left: 0; +} + +/* otherwise the dropdown changes the color on hover if no further options can be selected */ +.form-control:disabled, +.form-control[disabled] { + pointer-events: none; +} diff --git a/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.ts b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.ts new file mode 100644 index 000000000000..8b1bf7c58488 --- /dev/null +++ b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.component.ts @@ -0,0 +1,231 @@ +import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'; +import { NgbActiveModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap'; +import { faBackward, faFilter } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { SidebarCardElement, SidebarData } from 'app/types/sidebar'; +import { Observable, OperatorFunction, Subject, merge } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; +import { RangeSliderComponent } from 'app/shared/range-slider/range-slider.component'; +import { + DifficultyFilterOption, + ExerciseCategoryFilterOption, + ExerciseFilterOptions, + ExerciseFilterResults, + ExerciseTypeFilterOption, + FilterDetails, + FilterOption, + RangeFilter, +} from 'app/types/exercise-filter'; +import { satisfiesFilters } from 'app/shared/exercise-filter/exercise-filter-modal.helper'; +import { DifficultyLevel, ExerciseType } from 'app/entities/exercise.model'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { isRangeFilterApplied } from 'app/shared/sidebar/sidebar.helper'; + +@Component({ + selector: 'jhi-exercise-filter-modal', + templateUrl: './exercise-filter-modal.component.html', + styleUrls: ['./exercise-filter-modal.component.scss'], + standalone: true, + imports: [ + FormsModule, + ReactiveFormsModule, + FontAwesomeModule, + ArtemisSharedCommonModule, + ArtemisSharedComponentModule, + CustomExerciseCategoryBadgeComponent, + RangeSliderComponent, + ], +}) +export class ExerciseFilterModalComponent implements OnInit { + readonly faFilter = faFilter; + readonly faBackward = faBackward; + + @Output() filterApplied = new EventEmitter(); + + @ViewChild('categoriesFilterSelection', { static: false }) instance: NgbTypeahead; + + selectedCategoryOptions: ExerciseCategoryFilterOption[] = []; + selectableCategoryOptions: ExerciseCategoryFilterOption[] = []; + + noFiltersAvailable: boolean = false; + + focus$ = new Subject(); + click$ = new Subject(); + + form: FormGroup; + + model?: string; + + sidebarData?: SidebarData; + + categoryFilter?: FilterOption; + typeFilter?: FilterOption; + difficultyFilter?: FilterOption; + achievablePoints?: RangeFilter; + achievedScore?: RangeFilter; + + exerciseFilters?: ExerciseFilterOptions; + + constructor(private activeModal: NgbActiveModal) {} + + ngOnInit() { + this.categoryFilter = this.exerciseFilters?.categoryFilter; + this.typeFilter = this.exerciseFilters?.exerciseTypesFilter; + this.difficultyFilter = this.exerciseFilters?.difficultyFilter; + this.achievablePoints = this.exerciseFilters?.achievablePoints; + this.achievedScore = this.exerciseFilters?.achievedScore; + + this.noFiltersAvailable = !( + this.categoryFilter?.isDisplayed || + this.typeFilter?.isDisplayed || + this.difficultyFilter?.isDisplayed || + this.achievedScore?.isDisplayed || + this.achievablePoints?.isDisplayed + ); + + this.updateCategoryOptionsStates(); + } + + closeModal(): void { + this.activeModal.close(); + } + + search: OperatorFunction = (text$: Observable) => { + const debouncedText$ = text$.pipe(debounceTime(200), distinctUntilChanged()); + const clicksWithClosedPopup$ = this.click$.pipe(filter(() => !this.instance.isPopupOpen())); + const inputFocus$ = this.focus$; + + return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$).pipe( + map((term) => + term === '' + ? this.selectableCategoryOptions + : this.selectableCategoryOptions.filter((categoryFilter: ExerciseCategoryFilterOption) => { + if (categoryFilter.category.category !== undefined) { + return categoryFilter.category.category?.toLowerCase().indexOf(term.toLowerCase()) > -1; + } + + return false; + }), + ), + ); + }; + resultFormatter = (exerciseCategory: ExerciseCategoryFilterOption) => exerciseCategory.category.category ?? ''; + + onSelectItem(event: any) { + const isEnterPressedForNotExistingItem = !event.item; + if (isEnterPressedForNotExistingItem) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + event.preventDefault(); // otherwise clearing the input field will not work https://stackoverflow.com/questions/39783936/how-to-clear-the-typeahead-input-after-a-result-is-selected + const filterOption: ExerciseCategoryFilterOption = event.item; + filterOption.searched = true; + this.updateCategoryOptionsStates(); + this.model = undefined; // Clear the input field after selection + } + + removeItem(item: ExerciseCategoryFilterOption): () => void { + return () => { + item.searched = false; + this.updateCategoryOptionsStates(); + }; + } + + applyFilter(): void { + if (!this.sidebarData?.groupedData) { + return; + } + + const appliedFilterDetails = this.getAppliedFilterDetails(); + for (const groupedDataKey in this.sidebarData.groupedData) { + this.sidebarData.groupedData[groupedDataKey].entityData = this.sidebarData.groupedData[groupedDataKey].entityData.filter((sidebarElement) => + satisfiesFilters(sidebarElement, appliedFilterDetails), + ); + } + this.sidebarData.ungroupedData = this.sidebarData.ungroupedData?.filter((sidebarElement: SidebarCardElement) => satisfiesFilters(sidebarElement, appliedFilterDetails)); + + this.filterApplied.emit({ + filteredSidebarData: this.sidebarData, + appliedExerciseFilters: this.exerciseFilters, + isFilterActive: this.isFilterActive(appliedFilterDetails), + }); + + this.closeModal(); + } + + private getAppliedFilterDetails(): FilterDetails { + return { + searchedTypes: this.getSearchedTypes(), + selectedCategories: this.getSelectedCategories(), + searchedDifficulties: this.getSearchedDifficulties(), + isScoreFilterApplied: isRangeFilterApplied(this.achievedScore), + isPointsFilterApplied: isRangeFilterApplied(this.achievablePoints), + achievedScore: this.achievedScore, + achievablePoints: this.achievablePoints, + }; + } + + private getSearchedTypes(): ExerciseType[] | undefined { + return this.typeFilter?.options.filter((type) => type.checked).map((type) => type.value); + } + + private getSelectedCategories(): ExerciseCategory[] { + return this.selectedCategoryOptions + .filter((categoryOption: ExerciseCategoryFilterOption) => categoryOption.searched) + .map((categoryOption: ExerciseCategoryFilterOption) => categoryOption.category); + } + + private getSearchedDifficulties(): DifficultyLevel[] | undefined { + return this.difficultyFilter?.options.filter((difficulty) => difficulty.checked).map((difficulty) => difficulty.value); + } + + private isFilterActive(filterDetails: FilterDetails): boolean { + return ( + !!filterDetails.selectedCategories.length || + !!filterDetails.searchedTypes?.length || + !!filterDetails.searchedDifficulties?.length || + filterDetails.isScoreFilterApplied || + filterDetails.isPointsFilterApplied + ); + } + + clearFilter() { + this.categoryFilter?.options.forEach((categoryOption) => (categoryOption.searched = false)); + this.typeFilter?.options.forEach((typeOption) => (typeOption.checked = false)); + this.difficultyFilter?.options.forEach((difficultyOption) => (difficultyOption.checked = false)); + + this.resetRangeFilter(this.achievedScore); + this.resetRangeFilter(this.achievablePoints); + + this.applyFilter(); + } + + private resetRangeFilter(rangeFilter?: RangeFilter) { + if (!rangeFilter?.filter) { + return; + } + + const filter = rangeFilter.filter; + filter.selectedMin = filter.generalMin; + filter.selectedMax = filter.generalMax; + } + + private updateCategoryOptionsStates() { + this.selectedCategoryOptions = this.getUpdatedSelectedCategoryOptions(); + this.selectableCategoryOptions = this.getSelectableCategoryOptions(); + } + + private getUpdatedSelectedCategoryOptions(): ExerciseCategoryFilterOption[] { + return this.categoryFilter?.options.filter((categoryFilter) => categoryFilter.searched) ?? []; + } + + private getSelectableCategoryOptions(): ExerciseCategoryFilterOption[] { + return this.categoryFilter?.options.filter((categoryFilter) => !categoryFilter.searched) ?? []; + } +} diff --git a/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.helper.ts b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.helper.ts new file mode 100644 index 000000000000..de922fce195d --- /dev/null +++ b/src/main/webapp/app/shared/exercise-filter/exercise-filter-modal.helper.ts @@ -0,0 +1,84 @@ +import { SidebarCardElement } from 'app/types/sidebar'; +import { DifficultyLevel, ExerciseType } from 'app/entities/exercise.model'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { FilterDetails, RangeFilter } from 'app/types/exercise-filter'; +import { getLatestResultOfStudentParticipation } from 'app/exercises/shared/participation/participation.utils'; + +export function satisfiesDifficultyFilter(sidebarElement: SidebarCardElement, searchedDifficulties?: DifficultyLevel[]): boolean { + if (!searchedDifficulties?.length) { + return true; + } + if (!sidebarElement.difficulty) { + return false; + } + + return searchedDifficulties.includes(sidebarElement.difficulty); +} + +export function satisfiesTypeFilter(sidebarElement: SidebarCardElement, searchedTypes?: ExerciseType[]): boolean { + if (!searchedTypes?.length) { + return true; + } + if (!sidebarElement.exercise?.type) { + return false; + } + + return searchedTypes.includes(sidebarElement.exercise.type); +} + +export function satisfiesCategoryFilter(sidebarElement: SidebarCardElement, selectedCategories: ExerciseCategory[]): boolean { + if (!selectedCategories.length) { + return true; + } + if (!sidebarElement?.exercise?.categories) { + return false; + } + + // noinspection UnnecessaryLocalVariableJS: not inlined because the variable name improves readability + const isAnyExerciseCategoryMatchingASelectedCategory = sidebarElement.exercise.categories.some((category) => + selectedCategories.some((selectedCategory) => selectedCategory.equals(category)), + ); + return isAnyExerciseCategoryMatchingASelectedCategory; +} + +export function satisfiesScoreFilter(sidebarElement: SidebarCardElement, isFilterApplied: boolean, achievedScoreFilter?: RangeFilter): boolean { + if (!isFilterApplied || !achievedScoreFilter) { + return true; + } + + const latestResult = getLatestResultOfStudentParticipation(sidebarElement.studentParticipation, true); + if (!latestResult?.score) { + return achievedScoreFilter.filter.selectedMin === 0; + } + + const isScoreInSelectedMinRange = latestResult.score >= achievedScoreFilter.filter.selectedMin; + const isScoreInSelectedMaxRange = latestResult.score <= achievedScoreFilter.filter.selectedMax; + + return isScoreInSelectedMinRange && isScoreInSelectedMaxRange; +} + +export function satisfiesPointsFilter(sidebarElement: SidebarCardElement, isPointsFilterApplied: boolean, achievablePointsFilter?: RangeFilter): boolean { + if (!isPointsFilterApplied || !achievablePointsFilter) { + return true; + } + + /** {@link Exercise.maxPoints} must be in the range 1 - 9999 */ + if (!sidebarElement.exercise?.maxPoints) { + return false; + } + + const isAchievablePointsInSelectedMinRange = sidebarElement.exercise.maxPoints >= achievablePointsFilter.filter.selectedMin; + const isAchievablePointsInSelectedMaxRange = sidebarElement.exercise.maxPoints <= achievablePointsFilter.filter.selectedMax; + + return isAchievablePointsInSelectedMinRange && isAchievablePointsInSelectedMaxRange; +} + +export function satisfiesFilters(sidebarElement: SidebarCardElement, filterDetails: FilterDetails) { + return ( + satisfiesCategoryFilter(sidebarElement, filterDetails.selectedCategories) && + satisfiesDifficultyFilter(sidebarElement, filterDetails.searchedDifficulties) && + satisfiesTypeFilter(sidebarElement, filterDetails.searchedTypes) && + satisfiesScoreFilter(sidebarElement, filterDetails.isScoreFilterApplied, filterDetails.achievedScore) && + satisfiesPointsFilter(sidebarElement, filterDetails.isPointsFilterApplied, filterDetails.achievablePoints) + ); +} diff --git a/src/main/webapp/app/shared/range-slider/range-slider.component.html b/src/main/webapp/app/shared/range-slider/range-slider.component.html new file mode 100644 index 000000000000..cf6170502703 --- /dev/null +++ b/src/main/webapp/app/shared/range-slider/range-slider.component.html @@ -0,0 +1,32 @@ +
+
+
+ {{ selectedMinValue }}{{ labelSymbol }} + {{ selectedMaxValue }}{{ labelSymbol }} + + + +
+
diff --git a/src/main/webapp/app/shared/range-slider/range-slider.component.scss b/src/main/webapp/app/shared/range-slider/range-slider.component.scss new file mode 100644 index 000000000000..2342957d2e6d --- /dev/null +++ b/src/main/webapp/app/shared/range-slider/range-slider.component.scss @@ -0,0 +1,65 @@ +$height: 0.5rem; +$border-radius: 0.5rem; + +$slider-thumb-size: 1.2rem; +$slider-thumb-border-radius: 50%; +$slider-thumb-color: var(--primary); + +.slider { + height: $height; + border-radius: $border-radius; + background: var(--gray-400); +} + +.slider .progress { + height: $height; + border-radius: $border-radius; + position: absolute; + background: var(--primary); +} + +.range-input { + position: relative; +} + +.range-input input { + height: $height; + position: absolute; + width: 100%; + background: none; + pointer-events: none; + -webkit-appearance: none; +} + +/* Covers Chrome and Safari */ +[type='range']::-webkit-slider-thumb { + height: $slider-thumb-size; + width: $slider-thumb-size; + border-radius: $slider-thumb-border-radius; + background: $slider-thumb-color; + pointer-events: auto; + -webkit-appearance: none; + cursor: pointer; +} + +/* covers Mozilla Firefox */ +[type='range']::-moz-range-thumb { + height: $slider-thumb-size; + width: $slider-thumb-size; + border-radius: $slider-thumb-border-radius; + background: $slider-thumb-color; + pointer-events: auto; + -moz-appearance: none; + cursor: pointer; +} + +.slider-value { + position: absolute; + bottom: -2.2rem; + pointer-events: none; // we do not want to click the label but the range-thumb + color: var(--secondary); + text-align: center; + justify-content: center; + align-content: center; + display: flex; +} diff --git a/src/main/webapp/app/shared/range-slider/range-slider.component.ts b/src/main/webapp/app/shared/range-slider/range-slider.component.ts new file mode 100644 index 000000000000..1082ee793ca3 --- /dev/null +++ b/src/main/webapp/app/shared/range-slider/range-slider.component.ts @@ -0,0 +1,141 @@ +import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +const DEFAULT_STEP = 1; + +@Component({ + selector: 'jhi-range-slider', + templateUrl: './range-slider.component.html', + styleUrls: ['./range-slider.component.scss'], + standalone: true, + imports: [FormsModule, ReactiveFormsModule], +}) +export class RangeSliderComponent implements OnInit, OnDestroy { + @Input() generalMaxValue: number; + @Input() generalMinValue: number; + @Input() step: number = DEFAULT_STEP; + + /** When extending the supported label symbols you might have to adjust the logic for */ + @Input() labelSymbol?: '%'; + + @Input() selectedMinValue: number; + @Input() selectedMaxValue: number; + @Output() selectedMinValueChange: EventEmitter = new EventEmitter(); + @Output() selectedMaxValueChange: EventEmitter = new EventEmitter(); + + rangeInputElements?: NodeList; + eventListeners: { element: HTMLInputElement; listener: (event: Event) => void }[] = []; + + sliderMinPercentage: number; + sliderMaxPercentage: number; + + valueRange: number; + + /** Ensures that the label is placed centered underneath the range thumb */ + LABEL_MARGIN = 0.4; + + /** + * By trial and error it was found out that the slider thumbs are moving on + * 97% of the width compared to the colored bar that is displayed between the two thumbs. + * + * This issue is resolved with this factor when multiplied to {@link sliderMinPercentage} and {@link sliderMaxPercentage} + * to calculate the position of the label, as it is not the exact same position as the thumbs. + * + * + * To reproduce: + * If you inspect the progress bar in the initial state you will see that it is 100% wide and ends at the left end of + * the minimum range thumb. + * However, if you move the minimum thumb to the right (as far as possible), you will notice that the progress bar + * ends at the right end of the range thumb. - This is the problem that we address with this factor. + */ + SLIDER_THUMB_LABEL_POSITION_ADJUSTMENT_FACTOR = 0.97; + + constructor(private elRef: ElementRef) {} + + ngOnInit() { + this.rangeInputElements = this.elRef.nativeElement.querySelectorAll('.range-input input'); + + this.rangeInputElements?.forEach((input: HTMLInputElement) => { + const listener = (event: InputEvent) => { + this.ensureMinValueIsSmallerThanMaxValueViceVersa(event); + }; + input.addEventListener('input', listener); + this.eventListeners.push({ element: input, listener }); + }); + this.valueRange = this.generalMaxValue - this.generalMinValue; + + this.LABEL_MARGIN = this.getLabelMargin(); + + this.updateMinPercentage(); + this.updateMaxPercentage(); + } + + ngOnDestroy() { + this.eventListeners.forEach(({ element, listener }) => { + element.removeEventListener('input', listener); + }); + } + + updateMinPercentage() { + let newMinSelection = this.selectedMinValue; + + const tryingToSelectInvalidValue = this.selectedMinValue >= this.selectedMaxValue; + if (tryingToSelectInvalidValue) { + newMinSelection = this.selectedMaxValue - this.step; + } + + // noinspection UnnecessaryLocalVariableJS: not inlined because the variable name improves readability + const newMinPercentage = ((newMinSelection - this.generalMinValue) / this.valueRange) * 100; + this.sliderMinPercentage = newMinPercentage; + } + + updateMaxPercentage() { + let newMaxSelection = this.selectedMaxValue; + + const tryingToSelectInvalidValue = this.selectedMaxValue <= this.selectedMinValue; + if (tryingToSelectInvalidValue) { + newMaxSelection = this.selectedMinValue + this.step; + } + + // noinspection UnnecessaryLocalVariableJS: not inlined because the variable name improves readability + const newMaxPercentage = 100 - ((newMaxSelection - this.generalMinValue) / this.valueRange) * 100; + this.sliderMaxPercentage = newMaxPercentage; + } + + onSelectedMinValueChanged(event: Event): void { + const updatedMinValue = this.ensureMinValueIsSmallerThanMaxValueViceVersa(event); + this.selectedMinValueChange.emit(updatedMinValue); + } + + onSelectedMaxValueChanged(event: Event): void { + const updatedMaxValue = this.ensureMinValueIsSmallerThanMaxValueViceVersa(event); + this.selectedMaxValueChange.emit(updatedMaxValue); + } + + private ensureMinValueIsSmallerThanMaxValueViceVersa(event: Event): number { + const input = event.target as HTMLInputElement; + const minSliderIsUpdated = input.className.includes('range-min'); + + if (minSliderIsUpdated) { + if (this.selectedMinValue >= this.selectedMaxValue) { + this.selectedMinValue = this.selectedMaxValue - this.step; + } + return this.selectedMinValue; + } + + if (this.selectedMaxValue <= this.selectedMinValue) { + this.selectedMaxValue = this.selectedMinValue + this.step; + } + return this.selectedMaxValue; + } + + /** + * @return margin to labels considering the adjustments needed by the added {@link labelSymbol} + */ + private getLabelMargin() { + const BASE_LABEL_MARGIN = 0.4; // should be approximately the width of 1 symbol + const shiftToTheLeftDueToAddedSymbols = BASE_LABEL_MARGIN * (this.labelSymbol?.length ?? 0); + + return BASE_LABEL_MARGIN - shiftToTheLeftDueToAddedSymbols; + } +} diff --git a/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts b/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts index 5fef73b00b99..5cd59c0d3b8e 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts +++ b/src/main/webapp/app/shared/sidebar/sidebar-accordion/sidebar-accordion.component.ts @@ -24,10 +24,10 @@ export class SidebarAccordionComponent implements OnChanges, OnInit { @Input() showAddOption?: ChannelAccordionShowAdd; @Input() channelTypeIcon?: ChannelTypeIcons; @Input() collapseState: CollapseState; + @Input() isFilterActive: boolean = false; - //icon - faChevronRight = faChevronRight; - faFile = faFile; + readonly faChevronRight = faChevronRight; + readonly faFile = faFile; ngOnInit() { this.expandGroupWithSelectedItem(); @@ -35,7 +35,7 @@ export class SidebarAccordionComponent implements OnChanges, OnInit { } ngOnChanges() { - if (this.searchValue) { + if (this.searchValue || this.isFilterActive) { this.expandAll(); } else { this.setStoredCollapseState(); diff --git a/src/main/webapp/app/shared/sidebar/sidebar.component.html b/src/main/webapp/app/shared/sidebar/sidebar.component.html index c9ff6c8a64ae..ea513b48174e 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar.component.html +++ b/src/main/webapp/app/shared/sidebar/sidebar.component.html @@ -4,12 +4,28 @@ @if (searchFieldEnabled) { } @if (!sidebarData?.ungroupedData || !(sidebarData?.ungroupedData | searchFilter: ['title', 'type'] : searchValue)?.length) {
} @else { @@ -36,6 +52,7 @@ [channelTypeIcon]="channelTypeIcon" [collapseState]="collapseState" (onUpdateSidebar)="onUpdateSidebar.emit()" + [isFilterActive]="isFilterActive" /> } @else { @for (sidebarItem of sidebarData?.ungroupedData | searchFilter: ['title', 'type'] : searchValue; track sidebarItem; let last = $last) { diff --git a/src/main/webapp/app/shared/sidebar/sidebar.component.ts b/src/main/webapp/app/shared/sidebar/sidebar.component.ts index fdb0d9adcaaa..de47ff395f76 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar.component.ts +++ b/src/main/webapp/app/shared/sidebar/sidebar.component.ts @@ -1,10 +1,20 @@ import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core'; -import { faChevronRight, faFilter, faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; +import { faFilter, faFilterCircleXmark } from '@fortawesome/free-solid-svg-icons'; import { ActivatedRoute, Params } from '@angular/router'; import { Subscription, distinctUntilChanged } from 'rxjs'; import { ProfileService } from '../layouts/profiles/profile.service'; import { ChannelAccordionShowAdd, ChannelTypeIcons, CollapseState, SidebarCardSize, SidebarData, SidebarTypes } from 'app/types/sidebar'; import { SidebarEventService } from './sidebar-event.service'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { cloneDeep } from 'lodash-es'; +import { ExerciseFilterOptions, ExerciseFilterResults } from 'app/types/exercise-filter'; +import { + getAchievablePointsAndAchievedScoreFilterOptions, + getExerciseCategoryFilterOptions, + getExerciseDifficultyFilterOptions, + getExerciseTypeFilterOptions, +} from 'app/shared/sidebar/sidebar.helper'; +import { ExerciseFilterModalComponent } from 'app/shared/exercise-filter/exercise-filter-modal.component'; @Component({ selector: 'jhi-sidebar', @@ -22,6 +32,7 @@ export class SidebarComponent implements OnDestroy, OnChanges, OnInit { @Input() showAddOption?: ChannelAccordionShowAdd; @Input() channelTypeIcon?: ChannelTypeIcons; @Input() collapseState: CollapseState; + @Input() showFilter: boolean = false; searchValue = ''; isCollapsed: boolean = false; @@ -32,19 +43,26 @@ export class SidebarComponent implements OnDestroy, OnChanges, OnInit { profileSubscription?: Subscription; sidebarEventSubscription?: Subscription; sidebarAccordionEventSubscription?: Subscription; + routeParams: Params; isProduction = true; isTestServer = false; - // icons - faMagnifyingGlass = faMagnifyingGlass; - faChevronRight = faChevronRight; - faFilter = faFilter; + private modalRef?: NgbModalRef; + + readonly faFilter = faFilter; + readonly faFilterCurrentlyApplied = faFilterCircleXmark; + + sidebarDataBeforeFiltering: SidebarData; + + exerciseFilters?: ExerciseFilterOptions; + isFilterActive: boolean = false; constructor( private route: ActivatedRoute, private profileService: ProfileService, private sidebarEventService: SidebarEventService, + private modalService: NgbModal, ) {} ngOnInit(): void { @@ -110,4 +128,43 @@ export class SidebarComponent implements OnDestroy, OnChanges, OnInit { }; return this.sidebarData.sidebarType ? size[this.sidebarData.sidebarType] : 'M'; } + + openFilterExercisesDialog() { + this.initializeFilterOptions(); + + if (!this.sidebarDataBeforeFiltering) { + this.sidebarDataBeforeFiltering = cloneDeep(this.sidebarData); + } + + this.modalRef = this.modalService.open(ExerciseFilterModalComponent, { + size: 'lg', + backdrop: 'static', + animation: true, + }); + + this.modalRef.componentInstance.sidebarData = cloneDeep(this.sidebarDataBeforeFiltering); + this.modalRef.componentInstance.exerciseFilters = cloneDeep(this.exerciseFilters); + + this.modalRef.componentInstance.filterApplied.subscribe((exerciseFilterResults: ExerciseFilterResults) => { + this.sidebarData = exerciseFilterResults.filteredSidebarData!; + this.exerciseFilters = exerciseFilterResults.appliedExerciseFilters; + this.isFilterActive = exerciseFilterResults.isFilterActive; + }); + } + + initializeFilterOptions() { + if (this.exerciseFilters) { + return; + } + + const scoreAndPointsFilterOptions = getAchievablePointsAndAchievedScoreFilterOptions(this.sidebarData, this.exerciseFilters); + + this.exerciseFilters = { + categoryFilter: getExerciseCategoryFilterOptions(this.sidebarData, this.exerciseFilters), + exerciseTypesFilter: getExerciseTypeFilterOptions(this.sidebarData, this.exerciseFilters), + difficultyFilter: getExerciseDifficultyFilterOptions(this.sidebarData, this.exerciseFilters), + achievedScore: scoreAndPointsFilterOptions?.achievedScore, + achievablePoints: scoreAndPointsFilterOptions?.achievablePoints, + }; + } } diff --git a/src/main/webapp/app/shared/sidebar/sidebar.helper.ts b/src/main/webapp/app/shared/sidebar/sidebar.helper.ts new file mode 100644 index 000000000000..c77e558b5390 --- /dev/null +++ b/src/main/webapp/app/shared/sidebar/sidebar.helper.ts @@ -0,0 +1,269 @@ +import { DifficultyFilterOption, ExerciseCategoryFilterOption, ExerciseFilterOptions, ExerciseTypeFilterOption, FilterOption, RangeFilter } from 'app/types/exercise-filter'; +import { SidebarCardElement, SidebarData } from 'app/types/sidebar'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { DifficultyLevel, ExerciseType, getIcon } from 'app/entities/exercise.model'; +import { getLatestResultOfStudentParticipation } from 'app/exercises/shared/participation/participation.utils'; +import { roundToNextMultiple } from 'app/shared/util/utils'; + +const POINTS_STEP = 1; +const SCORE_THRESHOLD_TO_INCREASE_STEP = 20; +const SMALL_SCORE_STEP = 1; +const SCORE_STEP = 5; + +const DEFAULT_DIFFICULTIES_FILTER: DifficultyFilterOption[] = [ + { name: 'artemisApp.exercise.easy', value: DifficultyLevel.EASY, checked: false }, + { name: 'artemisApp.exercise.medium', value: DifficultyLevel.MEDIUM, checked: false }, + { name: 'artemisApp.exercise.hard', value: DifficultyLevel.HARD, checked: false }, +]; + +const DEFAULT_EXERCISE_TYPES_FILTER: ExerciseTypeFilterOption[] = [ + { name: 'artemisApp.courseStatistics.programming', value: ExerciseType.PROGRAMMING, checked: false, icon: getIcon(ExerciseType.PROGRAMMING) }, + { name: 'artemisApp.courseStatistics.quiz', value: ExerciseType.QUIZ, checked: false, icon: getIcon(ExerciseType.QUIZ) }, + { name: 'artemisApp.courseStatistics.modeling', value: ExerciseType.MODELING, checked: false, icon: getIcon(ExerciseType.MODELING) }, + { name: 'artemisApp.courseStatistics.text', value: ExerciseType.TEXT, checked: false, icon: getIcon(ExerciseType.TEXT) }, + { name: 'artemisApp.courseStatistics.file-upload', value: ExerciseType.FILE_UPLOAD, checked: false, icon: getIcon(ExerciseType.FILE_UPLOAD) }, +]; + +function getAvailableCategoriesAsFilterOptions(sidebarData?: SidebarData): ExerciseCategoryFilterOption[] | undefined { + const sidebarElementsWithExerciseCategory: SidebarCardElement[] | undefined = sidebarData?.ungroupedData?.filter( + (sidebarElement: SidebarCardElement) => sidebarElement.exercise?.categories !== undefined, + ); + const availableCategories: ExerciseCategory[] | undefined = sidebarElementsWithExerciseCategory?.flatMap( + (sidebarElement: SidebarCardElement) => sidebarElement.exercise?.categories || [], + ); + + // noinspection UnnecessaryLocalVariableJS: not inlined because the variable name improves readability + const availableCategoriesAsFilterOptions: ExerciseCategoryFilterOption[] | undefined = availableCategories?.map((category: ExerciseCategory) => ({ + category: category, + searched: false, + })); + return availableCategoriesAsFilterOptions; +} + +function getExerciseCategoryFilterOptionsWithoutDuplicates(exerciseCategoryFilterOptions?: ExerciseCategoryFilterOption[]): ExerciseCategoryFilterOption[] | undefined { + return exerciseCategoryFilterOptions?.reduce((unique: ExerciseCategoryFilterOption[], item: ExerciseCategoryFilterOption) => { + if (!unique.some((uniqueItem) => uniqueItem.category.equals(item.category))) { + unique.push(item); + } + return unique; + }, []); +} + +function sortExerciseCategoryFilterOptionsSortedByName(exerciseCategoryFilterOptions?: ExerciseCategoryFilterOption[]): ExerciseCategoryFilterOption[] { + return exerciseCategoryFilterOptions?.sort((categoryFilterOptionsA, categoryFilterOptionB) => categoryFilterOptionsA.category.compare(categoryFilterOptionB.category)) ?? []; +} + +/** + * @param exerciseFilters that might already be defined for the course sidebar + * @param sidebarData that contains the exercises of a course and their information + * + * @returns already defined category filter options if they exist, otherwise the category filter options based on the sidebar data + */ +export function getExerciseCategoryFilterOptions(sidebarData?: SidebarData, exerciseFilters?: ExerciseFilterOptions): FilterOption { + if (exerciseFilters?.categoryFilter) { + return exerciseFilters?.categoryFilter; + } + + const availableCategoriesAsFilterOptions = getAvailableCategoriesAsFilterOptions(sidebarData); + const selectableCategoryFilterOptions = getExerciseCategoryFilterOptionsWithoutDuplicates(availableCategoriesAsFilterOptions); + const sortedCategoryFilterOptions = sortExerciseCategoryFilterOptionsSortedByName(selectableCategoryFilterOptions); + + const isDisplayed = !!sortedCategoryFilterOptions.length; + return { isDisplayed: isDisplayed, options: selectableCategoryFilterOptions ?? [] }; +} + +/** + * @param exerciseFilters that might already be defined for the course sidebar + * @param sidebarData that contains the exercises of a course and their information + * + * @returns already defined exercise type filter options if they exist, otherwise the exercise type filter options based on the sidebar data + */ +export function getExerciseTypeFilterOptions(sidebarData?: SidebarData, exerciseFilters?: ExerciseFilterOptions): FilterOption { + if (exerciseFilters?.exerciseTypesFilter) { + return exerciseFilters?.exerciseTypesFilter; + } + + const existingExerciseTypes = sidebarData?.ungroupedData + ?.filter((sidebarElement: SidebarCardElement) => sidebarElement.type !== undefined) + .map((sidebarElement: SidebarCardElement) => sidebarElement.type); + + const availableTypeFilters = DEFAULT_EXERCISE_TYPES_FILTER?.filter((exerciseType) => existingExerciseTypes?.includes(exerciseType.value)); + + return { isDisplayed: availableTypeFilters.length > 1, options: availableTypeFilters }; +} + +/** + * @param exerciseFilters that might already be defined for the course sidebar + * @param sidebarData that contains the exercises of a course and their information + * + * @returns already defined difficulty filter options if they exist, otherwise the difficulty filter options based on the sidebar data + */ +export function getExerciseDifficultyFilterOptions(sidebarData?: SidebarData, exerciseFilters?: ExerciseFilterOptions): FilterOption { + if (exerciseFilters?.difficultyFilter) { + return exerciseFilters.difficultyFilter; + } + + const existingDifficulties = sidebarData?.ungroupedData + ?.filter((sidebarElement: SidebarCardElement) => sidebarElement.difficulty !== undefined) + .map((sidebarElement: SidebarCardElement) => sidebarElement.difficulty); + + const availableDifficultyFilters = DEFAULT_DIFFICULTIES_FILTER?.filter((difficulty) => existingDifficulties?.includes(difficulty.value)); + + return { isDisplayed: !!availableDifficultyFilters.length, options: availableDifficultyFilters }; +} + +export function isRangeFilterApplied(rangeFilter?: RangeFilter): boolean { + if (!rangeFilter?.filter) { + return false; + } + + const filter = rangeFilter.filter; + const isExcludingMinValues = filter.selectedMin !== filter.generalMin; + const isExcludingMaxValues = filter.selectedMax !== filter.generalMax; + return isExcludingMinValues || isExcludingMaxValues; +} + +function getUpdatedMinAndMaxValues(minValue: number, maxValue: number, currentMaxValue: number) { + let updatedMinValue = minValue; + let updatedMaxValue = maxValue; + + if (currentMaxValue < minValue) { + updatedMinValue = currentMaxValue; + } + if (currentMaxValue > maxValue) { + updatedMaxValue = currentMaxValue; + } + + return { updatedMinValue, updatedMaxValue }; +} + +/** + * The calculation for points and score are intentionally mixed into one method to reduce the number of iterations over the sidebar data. + * @param sidebarData + */ +function calculateMinAndMaxForPointsAndScore(sidebarData: SidebarData) { + let minAchievablePoints = Infinity; + let maxAchievablePoints = -Infinity; + + let minAchievedScore = Infinity; + let maxAchievedScore = -Infinity; + + sidebarData.ungroupedData?.forEach((sidebarElement: SidebarCardElement) => { + if (sidebarElement.exercise?.maxPoints) { + const currentExerciseMaxPoints = sidebarElement.exercise.maxPoints; + + const { updatedMinValue, updatedMaxValue } = getUpdatedMinAndMaxValues(minAchievablePoints, maxAchievablePoints, currentExerciseMaxPoints); + minAchievablePoints = updatedMinValue; + maxAchievablePoints = updatedMaxValue; + + if (sidebarElement.studentParticipation) { + const currentExerciseAchievedScore = getLatestResultOfStudentParticipation(sidebarElement.studentParticipation, true)?.score; + + if (currentExerciseAchievedScore !== undefined) { + const { updatedMinValue, updatedMaxValue } = getUpdatedMinAndMaxValues(minAchievedScore, maxAchievedScore, currentExerciseAchievedScore); + minAchievedScore = updatedMinValue; + maxAchievedScore = updatedMaxValue; + } + } + } + }); + + return { minAchievablePoints, maxAchievablePoints, minAchievedScore, maxAchievedScore }; +} + +/** + * **Rounds the min and max values for achievable points and achieved score to the next multiple of the step. + * The step {@link POINTS_STEP}, and {@link SCORE_STEP} or {@link SMALL_SCORE_STEP} are the selectable values for the range filter.** + *
+ * For the **score filter**, the step is increased if we have more than 20 values between the min and max value, + * as up to 100 values are theoretically possible.
+ * For the **achievable points filter**, the step is always 1 as exercises usually have between 1 and 15 points, + * so we do not need to increase the step and thereby limit accuracy of filter options.
+ * + * @param minAchievablePoints + * @param maxAchievablePoints + * @param minAchievedScore + * @param maxAchievedScore + */ +function roundRangeFilterMinAndMaxValues(minAchievablePoints: number, maxAchievablePoints: number, minAchievedScore: number, maxAchievedScore: number) { + const roundUp = true; + const roundDown = false; + const minAchievablePointsRounded = roundToNextMultiple(minAchievablePoints, POINTS_STEP, roundDown); + const maxAchievablePointsRounded = roundToNextMultiple(maxAchievablePoints, POINTS_STEP, roundUp); + + let minAchievedScoreRounded; + let maxAchievedScoreRounded; + + if (maxAchievedScore > SCORE_THRESHOLD_TO_INCREASE_STEP) { + minAchievedScoreRounded = roundToNextMultiple(minAchievedScore, SCORE_STEP, roundDown); + maxAchievedScoreRounded = roundToNextMultiple(maxAchievedScore, SCORE_STEP, roundUp); + } else { + minAchievedScoreRounded = roundToNextMultiple(minAchievedScore, SMALL_SCORE_STEP, roundDown); + maxAchievedScoreRounded = roundToNextMultiple(maxAchievedScore, SMALL_SCORE_STEP, roundUp); + } + + return { minAchievablePointsRounded, maxAchievablePointsRounded, minAchievedScoreRounded, maxAchievedScoreRounded }; +} + +function calculateAchievablePointsFilterOptions(sidebarData: SidebarData): { achievablePoints?: RangeFilter; achievedScore?: RangeFilter } { + const { minAchievablePoints, maxAchievablePoints, minAchievedScore, maxAchievedScore } = calculateMinAndMaxForPointsAndScore(sidebarData); + + const { minAchievablePointsRounded, maxAchievablePointsRounded, minAchievedScoreRounded, maxAchievedScoreRounded } = roundRangeFilterMinAndMaxValues( + minAchievablePoints, + maxAchievablePoints, + minAchievedScore, + maxAchievedScore, + ); + + return { + achievablePoints: { + isDisplayed: minAchievablePointsRounded < maxAchievablePointsRounded, + filter: { + generalMin: minAchievablePointsRounded, + generalMax: maxAchievablePointsRounded, + selectedMin: minAchievablePointsRounded, + selectedMax: maxAchievablePointsRounded, + step: POINTS_STEP, + }, + }, + achievedScore: { + isDisplayed: minAchievedScoreRounded < maxAchievedScoreRounded && minAchievedScoreRounded !== Infinity, + filter: { + generalMin: minAchievedScoreRounded, + generalMax: maxAchievedScoreRounded, + selectedMin: minAchievedScoreRounded, + selectedMax: maxAchievedScoreRounded, + step: maxAchievedScoreRounded <= SCORE_THRESHOLD_TO_INCREASE_STEP ? SMALL_SCORE_STEP : SCORE_STEP, + }, + }, + }; +} + +/** + * @param exerciseFilters that might already be defined for the course sidebar + * @param sidebarData that contains the exercises of a course and their information + * + * @returns already defined achievable points and achieved score filter options if they exist, otherwise the achievable points and achieved score filter options based on the sidebar data + */ +export function getAchievablePointsAndAchievedScoreFilterOptions( + sidebarData?: SidebarData, + exerciseFilters?: ExerciseFilterOptions, +): { + achievablePoints?: RangeFilter; + achievedScore?: RangeFilter; +} { + if (!sidebarData?.ungroupedData) { + return { achievablePoints: undefined, achievedScore: undefined }; + } + + const isPointsFilterApplied = isRangeFilterApplied(exerciseFilters?.achievablePoints); + const isScoreFilterApplied = isRangeFilterApplied(exerciseFilters?.achievedScore); + + const isRecalculatingFilterOptionsRequired = isPointsFilterApplied || isScoreFilterApplied || !exerciseFilters?.achievablePoints || !exerciseFilters?.achievedScore; + if (!isRecalculatingFilterOptionsRequired) { + // the scores might change when we work on exercises, so we re-calculate the filter options (but only if the filter is actually applied) + return { achievablePoints: exerciseFilters?.achievablePoints, achievedScore: exerciseFilters?.achievedScore }; + } + + return calculateAchievablePointsFilterOptions(sidebarData); +} diff --git a/src/main/webapp/app/shared/statistics-graph/statistics.service.ts b/src/main/webapp/app/shared/statistics-graph/statistics.service.ts index 27b83a0eac33..233a10bfd8d8 100644 --- a/src/main/webapp/app/shared/statistics-graph/statistics.service.ts +++ b/src/main/webapp/app/shared/statistics-graph/statistics.service.ts @@ -48,7 +48,7 @@ export class StatisticsService { const params = new HttpParams().set('courseId', '' + courseId); return this.http.get(`${this.resourceUrl}course-statistics`, { params }).pipe( map((res: CourseManagementStatisticsDTO) => { - StatisticsService.convertExerciseCategoriesOfrCourseManagementStatisticsFromServer(res); + StatisticsService.convertExerciseCategoriesOfCourseManagementStatisticsFromServer(res); return StatisticsService.convertCourseManagementStatisticDatesFromServer(res); }), ); @@ -78,9 +78,9 @@ export class StatisticsService { return dto; } - private static convertExerciseCategoriesOfrCourseManagementStatisticsFromServer(res: CourseManagementStatisticsDTO): CourseManagementStatisticsDTO { + private static convertExerciseCategoriesOfCourseManagementStatisticsFromServer(res: CourseManagementStatisticsDTO): CourseManagementStatisticsDTO { res.averageScoresOfExercises.forEach((avgScoresOfExercise) => { - avgScoresOfExercise.categories = avgScoresOfExercise.categories?.map((category) => JSON.parse(category as string) as ExerciseCategory); + avgScoresOfExercise.categories = avgScoresOfExercise.categories?.map((category) => new ExerciseCategory(category.category, category.color)); }); return res; } diff --git a/src/main/webapp/app/shared/util/utils.ts b/src/main/webapp/app/shared/util/utils.ts index a9ecd05f46d7..ed3df211540a 100644 --- a/src/main/webapp/app/shared/util/utils.ts +++ b/src/main/webapp/app/shared/util/utils.ts @@ -156,3 +156,18 @@ export function scrollToTopOfPage() { export function isExamExercise(exercise: Exercise) { return exercise.course === undefined; } + +/** + * Rounds a value up to the nearest multiple + * + * @param value that shall be rounded + * @param multiple to which we round up + * @param roundUp if true, we round up, otherwise we round down + */ +export function roundToNextMultiple(value: number, multiple: number, roundUp: boolean) { + if (roundUp) { + return Math.ceil(value / multiple) * multiple; + } + + return Math.floor(value / multiple) * multiple; +} diff --git a/src/main/webapp/app/types/exercise-filter.ts b/src/main/webapp/app/types/exercise-filter.ts new file mode 100644 index 000000000000..053dbb8d089c --- /dev/null +++ b/src/main/webapp/app/types/exercise-filter.ts @@ -0,0 +1,43 @@ +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { DifficultyLevel, ExerciseType } from 'app/entities/exercise.model'; +import { SidebarData } from 'app/types/sidebar'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; + +/** + * isDisplayed - whether the filter is in the filter modal (e.g. for no sidebar element the difficulty is defined, so the difficulty filter is not displayed) + */ +export type FilterOption = { isDisplayed: boolean; options: T[] }; +export type ExerciseCategoryFilterOption = { category: ExerciseCategory; searched: boolean }; +export type ExerciseTypeFilterOption = { name: string; value: ExerciseType; checked: boolean; icon: IconProp }; +export type DifficultyFilterOption = { name: string; value: DifficultyLevel; checked: boolean }; + +export type RangeFilter = { + isDisplayed: boolean; + filter: { + generalMin: number; + generalMax: number; + selectedMin: number; + selectedMax: number; + step: number; + }; +}; + +export type ExerciseFilterOptions = { + categoryFilter?: FilterOption; + exerciseTypesFilter?: FilterOption; + difficultyFilter?: FilterOption; + achievedScore?: RangeFilter; + achievablePoints?: RangeFilter; +}; + +export type ExerciseFilterResults = { filteredSidebarData?: SidebarData; appliedExerciseFilters?: ExerciseFilterOptions; isFilterActive: boolean }; + +export type FilterDetails = { + searchedTypes?: ExerciseType[]; + selectedCategories: ExerciseCategory[]; + searchedDifficulties?: DifficultyLevel[]; + isScoreFilterApplied: boolean; + isPointsFilterApplied: boolean; + achievedScore?: RangeFilter; + achievablePoints?: RangeFilter; +}; diff --git a/src/main/webapp/i18n/de/exercise.json b/src/main/webapp/i18n/de/exercise.json index e198497deaa9..332f39024ff4 100644 --- a/src/main/webapp/i18n/de/exercise.json +++ b/src/main/webapp/i18n/de/exercise.json @@ -140,6 +140,7 @@ }, "categories": "Kategorien", "noCategory": "Keine Kategorie", + "selectCategories": "Kategorien auswählen", "participation": "Teilnahme", "participations": "Teilnahmen", "submissions": "Einreichungen", diff --git a/src/main/webapp/i18n/de/student-dashboard.json b/src/main/webapp/i18n/de/student-dashboard.json index 38820f89e17b..89729023ff10 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -33,7 +33,8 @@ "manage": "Verwalten", "studentView": "Studentenansicht", "general": { - "noDataFound": "Keine Einträge gefunden." + "noDataFound": "Keine Einträge gefunden.", + "noElementFoundWithAppliedFilter": "Keine Einträge für die angewendeten Filtereinstellungen gefunden." }, "sidebar": { "past": "Vorangegangen", @@ -89,6 +90,17 @@ "plagiarismCases": "Plagiatsfälle", "gradingSystem": "Notenschlüssel" }, + "exerciseFilter": { + "filter": "Filter", + "modalTitle": "Aufgaben Filtern", + "dueDateRange": "Zeitraum der Einreichungsfrist", + "achievedScore": "Erreichtes Ergebnis", + "achievablePoints": "Erreichbare Punktzahl", + "applyFilter": "Filter anwenden", + "resetFilter": "Filter zurücksetzen", + "noFilterAvailable": "Für die bisherigen Aufgaben gibt es keine unterscheidenden Filteroptionen", + "noMoreOptions": "Keine weiteren Auswahlmöglichkeiten" + }, "exerciseList": { "filter": "Filter ({{ num }})", "releaseDate": "Veröffentlichungsdatum", diff --git a/src/main/webapp/i18n/en/exercise.json b/src/main/webapp/i18n/en/exercise.json index fbc47e4093dc..ca2f0fdde23c 100644 --- a/src/main/webapp/i18n/en/exercise.json +++ b/src/main/webapp/i18n/en/exercise.json @@ -140,6 +140,7 @@ }, "categories": "Categories", "noCategory": "No Category", + "selectCategories": "Select categories", "participation": "Participation", "participations": "Participations", "submissions": "Submissions", diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index 586cdf840ce1..ae6d54800cd6 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -33,7 +33,8 @@ "manage": "Manage", "studentView": "Student view", "general": { - "noDataFound": "No data found." + "noDataFound": "No data found.", + "noElementFoundWithAppliedFilter": "No element matches the applied filter." }, "sidebar": { "past": "Past", @@ -89,6 +90,17 @@ "plagiarismCases": "Plagiarism Cases", "gradingSystem": "Grading System" }, + "exerciseFilter": { + "filter": "Filter", + "modalTitle": "Filter Exercises", + "dueDateRange": "Due Date Range", + "achievedScore": "Achieved Score", + "achievablePoints": "Achievable Points", + "applyFilter": "Apply filter", + "clearFilter": "Clear filter", + "noFilterAvailable": "There are no distinguishing filter options for the existing exercises", + "noMoreOptions": "No more options" + }, "exerciseList": { "filter": "Filter ({{ num }})", "releaseDate": "Release Date", diff --git a/src/test/javascript/spec/component/course/course-exercises.component.spec.ts b/src/test/javascript/spec/component/course/course-exercises.component.spec.ts index 017814b34c5e..3a2e57aa1588 100644 --- a/src/test/javascript/spec/component/course/course-exercises.component.spec.ts +++ b/src/test/javascript/spec/component/course/course-exercises.component.spec.ts @@ -29,6 +29,7 @@ import { SidebarComponent } from 'app/shared/sidebar/sidebar.component'; import { SearchFilterPipe } from 'app/shared/pipes/search-filter.pipe'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { By } from '@angular/platform-browser'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; describe('CourseExercisesComponent', () => { let fixture: ComponentFixture; @@ -44,7 +45,7 @@ describe('CourseExercisesComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, FormsModule, RouterTestingModule.withRoutes([]), MockModule(ReactiveFormsModule)], + imports: [ArtemisTestModule, FormsModule, RouterTestingModule.withRoutes([]), MockModule(ReactiveFormsModule), MockDirective(TranslateDirective)], declarations: [ CourseExercisesComponent, SidebarComponent, diff --git a/src/test/javascript/spec/component/course/exercise-filter.model.spec.ts b/src/test/javascript/spec/component/course/exercise-filter.model.spec.ts index 8aadaa3d2189..232aab57fafa 100644 --- a/src/test/javascript/spec/component/course/exercise-filter.model.spec.ts +++ b/src/test/javascript/spec/component/course/exercise-filter.model.spec.ts @@ -6,10 +6,8 @@ import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; import { Exercise } from 'app/entities/exercise.model'; describe('Exercise Filter Test', () => { - const category1 = new ExerciseCategory(); - category1.category = 'Easy'; - const category2 = new ExerciseCategory(); - category2.category = 'Hard'; + const category1 = new ExerciseCategory('Easy', undefined); + const category2 = new ExerciseCategory('Hard', undefined); const course: Course = { id: 123 } as Course; const exercise1 = new ProgrammingExercise(course, undefined); exercise1.id = 1; diff --git a/src/test/javascript/spec/component/exercises/shared/headers/header-exercise-page-with-details.component.spec.ts b/src/test/javascript/spec/component/exercises/shared/headers/header-exercise-page-with-details.component.spec.ts index 6d38f089db69..6682b12aa12c 100644 --- a/src/test/javascript/spec/component/exercises/shared/headers/header-exercise-page-with-details.component.spec.ts +++ b/src/test/javascript/spec/component/exercises/shared/headers/header-exercise-page-with-details.component.spec.ts @@ -67,8 +67,7 @@ describe('HeaderExercisePageWithDetails', () => { expect(component.icon.iconName).toBe('keyboard'); // dueDate, categories, examMode should also be set if the necessary information is known - const category = new ExerciseCategory(); - category.category = 'testcategory'; + const category = new ExerciseCategory('testcategory', undefined); const categories = [category]; exercise.categories = categories; exam.endDate = dayjs().subtract(1, 'day'); diff --git a/src/test/javascript/spec/component/file-upload-exercise/file-upload-exercise-update.component.spec.ts b/src/test/javascript/spec/component/file-upload-exercise/file-upload-exercise-update.component.spec.ts index d104f657bdf3..caec2f8dbd54 100644 --- a/src/test/javascript/spec/component/file-upload-exercise/file-upload-exercise-update.component.spec.ts +++ b/src/test/javascript/spec/component/file-upload-exercise/file-upload-exercise-update.component.spec.ts @@ -23,6 +23,7 @@ import { fileUploadExercise } from '../../helpers/mocks/service/mock-file-upload import { ExerciseTitleChannelNameComponent } from 'app/exercises/shared/exercise-title-channel-name/exercise-title-channel-name.component'; import { TeamConfigFormGroupComponent } from 'app/exercises/shared/team-config-form-group/team-config-form-group.component'; import { NgModel } from '@angular/forms'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; describe('FileUploadExerciseUpdateComponent', () => { let comp: FileUploadExerciseUpdateComponent; @@ -321,7 +322,7 @@ describe('FileUploadExerciseUpdateComponent', () => { it('should updateCategories properly by making category available for selection again when removing it', () => { comp.fileUploadExercise = fileUploadExercise; comp.exerciseCategories = []; - const newCategories = [{ category: 'Easy' }, { category: 'Hard' }]; + const newCategories = [new ExerciseCategory('Easy', undefined), new ExerciseCategory('Hard', undefined)]; comp.updateCategories(newCategories); diff --git a/src/test/javascript/spec/component/modeling-exercise/modeling-exercise-update.component.spec.ts b/src/test/javascript/spec/component/modeling-exercise/modeling-exercise-update.component.spec.ts index b05efdeb91e5..f9963b3f1725 100644 --- a/src/test/javascript/spec/component/modeling-exercise/modeling-exercise-update.component.spec.ts +++ b/src/test/javascript/spec/component/modeling-exercise/modeling-exercise-update.component.spec.ts @@ -27,6 +27,7 @@ import { ExerciseUpdatePlagiarismComponent } from 'app/exercises/shared/plagiari import { NgModel } from '@angular/forms'; import { TeamConfigFormGroupComponent } from 'app/exercises/shared/team-config-form-group/team-config-form-group.component'; import { UMLDiagramType } from '@ls1intum/apollon'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; describe('ModelingExerciseUpdateComponent', () => { let comp: ModelingExerciseUpdateComponent; @@ -34,7 +35,8 @@ describe('ModelingExerciseUpdateComponent', () => { let service: ModelingExerciseService; let courseService: CourseManagementService; let exerciseService: ExerciseService; - const categories = [{ category: 'testCat' }, { category: 'testCat2' }]; + const categories = [new ExerciseCategory('testCat', undefined), new ExerciseCategory('testCat2', undefined)]; + const categoriesStringified = categories.map((cat) => JSON.stringify(cat)); beforeEach(() => { @@ -262,7 +264,8 @@ describe('ModelingExerciseUpdateComponent', () => { const modelingExercise = new ModelingExercise(UMLDiagramType.ClassDiagram, undefined, undefined); modelingExercise.categories = categories; comp.modelingExercise = modelingExercise; - const newCategories = [{ category: 'newCat1' }, { category: 'newCat2' }]; + const newCategories = [new ExerciseCategory('newCat1', undefined), new ExerciseCategory('newCat2', undefined)]; + comp.updateCategories(newCategories); expect(comp.modelingExercise.categories).toEqual(newCategories); }); @@ -285,7 +288,7 @@ describe('ModelingExerciseUpdateComponent', () => { it('should updateCategories properly by making category available for selection again when removing it', () => { comp.modelingExercise = new ModelingExercise(UMLDiagramType.ClassDiagram, undefined, undefined); comp.exerciseCategories = []; - const newCategories = [{ category: 'Easy' }, { category: 'Hard' }]; + const newCategories = [new ExerciseCategory('Easy', undefined), new ExerciseCategory('Hard', undefined)]; comp.updateCategories(newCategories); diff --git a/src/test/javascript/spec/component/overview/course-exams/course-exams.component.spec.ts b/src/test/javascript/spec/component/overview/course-exams/course-exams.component.spec.ts index cabb62f2ae39..4b6ccb1ca3f1 100644 --- a/src/test/javascript/spec/component/overview/course-exams/course-exams.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-exams/course-exams.component.spec.ts @@ -5,20 +5,21 @@ import { CourseExamsComponent } from 'app/overview/course-exams/course-exams.com import { Exam } from 'app/entities/exam.model'; import { ArtemisTestModule } from '../../../test.module'; import dayjs from 'dayjs/esm'; -import { MockModule, MockPipe, MockProvider } from 'ng-mocks'; +import { 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'; import { StudentExam } from 'app/entities/student-exam.model'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { CourseStorageService } from 'app/course/manage/course-storage.service'; -import { SidebarComponent } from '../../../../../../main/webapp/app/shared/sidebar/sidebar.component'; +import { SidebarComponent } from 'app/shared/sidebar/sidebar.component'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { SearchFilterPipe } from 'app/shared/pipes/search-filter.pipe'; import { RouterTestingModule } from '@angular/router/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MockRouter } from '../../../helpers/mocks/mock-router'; import { CourseOverviewService } from 'app/overview/course-overview.service'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; describe('CourseExamsComponent', () => { let component: CourseExamsComponent; @@ -100,7 +101,7 @@ describe('CourseExamsComponent', () => { router.navigate.mockImplementation(() => Promise.resolve(true)); TestBed.configureTestingModule({ - imports: [ArtemisTestModule, RouterTestingModule, MockModule(FormsModule), MockModule(ReactiveFormsModule)], + imports: [ArtemisTestModule, RouterTestingModule, MockModule(FormsModule), MockModule(ReactiveFormsModule), MockDirective(TranslateDirective)], declarations: [CourseExamsComponent, SidebarComponent, SearchFilterComponent, MockPipe(ArtemisTranslatePipe), MockPipe(SearchFilterPipe)], providers: [ { provide: Router, useValue: router }, diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts index df1e7b8924b0..c4f40f4b79dc 100644 --- a/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise-update.component.spec.ts @@ -975,7 +975,7 @@ describe('ProgrammingExerciseUpdateComponent', () => { fixture.detectChanges(); tick(); - const categories = [new ExerciseCategory()]; + const categories = [new ExerciseCategory(undefined, undefined)]; expect(comp.exerciseCategories).toBeUndefined(); comp.updateCategories(categories); expect(comp.exerciseCategories).toBe(categories); diff --git a/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts b/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts index 60940086d9d3..a7e1309fcea0 100644 --- a/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts +++ b/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts @@ -40,8 +40,9 @@ import { MockProvider } from 'ng-mocks'; import { Duration } from 'app/exercises/quiz/manage/quiz-exercise-interfaces'; import { QuizQuestionListEditComponent } from 'app/exercises/quiz/manage/quiz-question-list-edit.component'; import { MockNgbModalService } from '../../helpers/mocks/service/mock-ngb-modal.service'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; -describe('QuizExercise Update Detail Component', () => { +describe('QuizExerciseUpdateComponent', () => { let comp: QuizExerciseUpdateComponent; let exerciseGroupService: ExerciseGroupService; let courseManagementService: CourseManagementService; @@ -447,7 +448,7 @@ describe('QuizExercise Update Detail Component', () => { it('should updateCategories properly by making category available for selection again when removing it', () => { comp.quizExercise = quizExercise; comp.exerciseCategories = []; - const newCategories = [{ category: 'Easy' }, { category: 'Hard' }]; + const newCategories = [new ExerciseCategory('Easy', undefined), new ExerciseCategory('Hard', undefined)]; comp.updateCategories(newCategories); @@ -583,8 +584,8 @@ describe('QuizExercise Update Detail Component', () => { it('should update categories to given categories', () => { resetQuizExercise(); comp.quizExercise = quizExercise; - const exerciseCategory1 = { exerciseId: 1, category: 'category1', color: 'color1' }; - const exerciseCategory2 = { exerciseId: 1, category: 'category1', color: 'color1' }; + const exerciseCategory1 = new ExerciseCategory('category1', 'color1'); + const exerciseCategory2 = new ExerciseCategory('category1', 'color1'); const expected = [exerciseCategory1, exerciseCategory2]; comp.updateCategories([exerciseCategory1, exerciseCategory2]); expect(comp.quizExercise.categories).toEqual(expected); diff --git a/src/test/javascript/spec/component/range-slider.component.spec.ts b/src/test/javascript/spec/component/range-slider.component.spec.ts new file mode 100644 index 000000000000..7e1b747047e0 --- /dev/null +++ b/src/test/javascript/spec/component/range-slider.component.spec.ts @@ -0,0 +1,69 @@ +import { RangeSliderComponent } from 'app/shared/range-slider/range-slider.component'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MockModule } from 'ng-mocks'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +describe('RangeSliderComponent', () => { + let component: RangeSliderComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [MockModule(FormsModule), MockModule(ReactiveFormsModule)], + declarations: [RangeSliderComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RangeSliderComponent); + component = fixture.componentInstance; + + component.generalMinValue = 0; + component.generalMaxValue = 100; + component.selectedMinValue = 20; + component.selectedMaxValue = 80; + component.step = 5; + }); + + it('should emit the updated max value', () => { + const emitSpy = jest.spyOn(component.selectedMaxValueChange, 'emit'); + + component.selectedMaxValue = 90; + const event = new Event('input'); + Object.defineProperty(event, 'target', { value: { className: 'range-max', value: 90 } }); + + component.onSelectedMaxValueChanged(event); + expect(emitSpy).toHaveBeenCalledWith(90); + }); + + it('should emit the updated max value rounded up to next selectable value', () => { + const emitSpy = jest.spyOn(component.selectedMaxValueChange, 'emit'); + + component.selectedMaxValue = 11; + const event = new Event('input'); + Object.defineProperty(event, 'target', { value: { className: 'range-max', value: 11 } }); + + component.onSelectedMaxValueChanged(event); + expect(emitSpy).toHaveBeenCalledWith(25); + }); + + it('should emit the updated min value', () => { + const emitSpy = jest.spyOn(component.selectedMinValueChange, 'emit'); + + component.selectedMinValue = 30; + const event = new Event('input'); + Object.defineProperty(event, 'target', { value: { className: 'range-min', value: 30 } }); + + component.onSelectedMinValueChanged(event); + expect(emitSpy).toHaveBeenCalledWith(30); + }); + + it('should emit the updated min value rounded down to next selectable value', () => { + const emitSpy = jest.spyOn(component.selectedMinValueChange, 'emit'); + + component.selectedMinValue = 99; + const event = new Event('input'); + Object.defineProperty(event, 'target', { value: { className: 'range-min', value: 99 } }); + + component.onSelectedMinValueChanged(event); + expect(emitSpy).toHaveBeenCalledWith(75); + }); +}); diff --git a/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.component.spec.ts b/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.component.spec.ts new file mode 100644 index 000000000000..3432102e0a9d --- /dev/null +++ b/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.component.spec.ts @@ -0,0 +1,259 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ExerciseFilterModalComponent } from 'app/shared/exercise-filter/exercise-filter-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { MockComponent, MockModule, MockProvider } from 'ng-mocks'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; +import { RangeSliderComponent } from 'app/shared/range-slider/range-slider.component'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { RangeFilter } from 'app/types/exercise-filter'; +import { DifficultyLevel, Exercise, ExerciseType, getIcon } from 'app/entities/exercise.model'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { SidebarCardElement } from 'app/types/sidebar'; +import { Result } from 'app/entities/result.model'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; + +const EXERCISE_1 = { categories: [new ExerciseCategory('category1', undefined), new ExerciseCategory('category2', '#1b97ca')], maxPoints: 5, type: ExerciseType.TEXT } as Exercise; +const EXERCISE_2 = { + categories: [new ExerciseCategory('category3', '#0d3cc2'), new ExerciseCategory('category4', '#6ae8ac')], + maxPoints: 5, + type: ExerciseType.PROGRAMMING, +} as Exercise; +const EXERCISE_3 = { + categories: [new ExerciseCategory('category1', undefined), new ExerciseCategory('category4', '#6ae8ac')], + maxPoints: 8, + type: ExerciseType.PROGRAMMING, +} as Exercise; + +const SIDEBAR_CARD_ELEMENT_1 = { + exercise: EXERCISE_1, + type: ExerciseType.TEXT, + difficulty: DifficultyLevel.HARD, + studentParticipation: { results: [{ score: 7.7 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_2 = { + exercise: EXERCISE_2, + type: ExerciseType.PROGRAMMING, + difficulty: DifficultyLevel.EASY, + studentParticipation: { results: [{ score: 5.0 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_3 = { + exercise: EXERCISE_3, + type: ExerciseType.PROGRAMMING, + difficulty: DifficultyLevel.EASY, + studentParticipation: { results: [{ score: 82.3 } as Result] } as StudentParticipation, +} as SidebarCardElement; + +const SCORE_FILTER: RangeFilter = { + isDisplayed: true, + filter: { + generalMin: 0, + generalMax: 100, + selectedMin: 0, + selectedMax: 100, + step: 5, + }, +}; + +const POINTS_FILTER: RangeFilter = { + isDisplayed: true, + filter: { + generalMin: 0, + generalMax: 20, + selectedMin: 0, + selectedMax: 20, + step: 1, + }, +}; + +describe('ExerciseFilterModalComponent', () => { + let component: ExerciseFilterModalComponent; + let fixture: ComponentFixture; + let activeModal: NgbActiveModal; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + MockModule(FormsModule), + MockModule(ReactiveFormsModule), + MockModule(FontAwesomeModule), + MockModule(ArtemisSharedCommonModule), + MockModule(ArtemisSharedComponentModule), + ], + declarations: [ExerciseFilterModalComponent, MockComponent(CustomExerciseCategoryBadgeComponent), MockComponent(RangeSliderComponent)], + providers: [MockProvider(NgbActiveModal)], + }).compileComponents(); + + fixture = TestBed.createComponent(ExerciseFilterModalComponent); + component = fixture.componentInstance; + activeModal = TestBed.inject(NgbActiveModal); + + component.exerciseFilters = { + exerciseTypesFilter: { + isDisplayed: true, + options: [ + { name: 'artemisApp.courseStatistics.programming', value: ExerciseType.PROGRAMMING, checked: false, icon: getIcon(ExerciseType.PROGRAMMING) }, + { name: 'artemisApp.courseStatistics.quiz', value: ExerciseType.QUIZ, checked: false, icon: getIcon(ExerciseType.QUIZ) }, + { name: 'artemisApp.courseStatistics.modeling', value: ExerciseType.MODELING, checked: false, icon: getIcon(ExerciseType.MODELING) }, + { name: 'artemisApp.courseStatistics.text', value: ExerciseType.TEXT, checked: false, icon: getIcon(ExerciseType.TEXT) }, + { name: 'artemisApp.courseStatistics.file-upload', value: ExerciseType.FILE_UPLOAD, checked: false, icon: getIcon(ExerciseType.FILE_UPLOAD) }, + ], + }, + difficultyFilter: { + isDisplayed: true, + options: [ + { name: 'artemisApp.exercise.easy', value: DifficultyLevel.EASY, checked: false }, + { name: 'artemisApp.exercise.medium', value: DifficultyLevel.MEDIUM, checked: false }, + { name: 'artemisApp.exercise.hard', value: DifficultyLevel.HARD, checked: false }, + ], + }, + categoryFilter: { + isDisplayed: true, + options: [ + { category: new ExerciseCategory('category1', undefined), searched: false }, + { category: new ExerciseCategory('category2', undefined), searched: false }, + ], + }, + achievedScore: SCORE_FILTER, + achievablePoints: POINTS_FILTER, + }; + + fixture.detectChanges(); + }); + + it('should initialize filters properly', () => { + expect(component.categoryFilter).toEqual(component.exerciseFilters?.categoryFilter); + expect(component.typeFilter).toEqual(component.exerciseFilters?.exerciseTypesFilter); + expect(component.difficultyFilter).toEqual(component.exerciseFilters?.difficultyFilter); + expect(component.achievedScore).toEqual(component.exerciseFilters?.achievedScore); + expect(component.achievablePoints).toEqual(component.exerciseFilters?.achievablePoints); + }); + + describe('should close modal', () => { + it('with button in upper right corner on click', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + const closeModalSpy = jest.spyOn(component, 'closeModal'); + + const closeButton = fixture.debugElement.query(By.css('.btn-close')); + expect(closeButton).not.toBeNull(); + + closeButton.nativeElement.click(); + expect(closeSpy).toHaveBeenCalledOnce(); + expect(closeModalSpy).toHaveBeenCalledOnce(); + }); + + it('with button in lower right corner on click', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + const closeModalSpy = jest.spyOn(component, 'closeModal'); + + const cancelButton = fixture.debugElement.query(By.css('button[jhiTranslate="entity.action.cancel"]')); + expect(cancelButton).not.toBeNull(); + + cancelButton.nativeElement.click(); + expect(closeSpy).toHaveBeenCalledOnce(); + expect(closeModalSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('select category', () => { + it('should mark a category as selected when category is found', () => { + expect(component.categoryFilter?.options[0].searched).toBeFalse(); // if it is not false in the beginning we do not test anything here + const onSelectItemSpy = jest.spyOn(component, 'onSelectItem'); + + component.model = 'category1'; + // Simulate selecting an item + const event = { + item: component.selectableCategoryOptions[0], + preventDefault: jest.fn(), + }; + component.onSelectItem(event); + fixture.detectChanges(); + + expect(onSelectItemSpy).toHaveBeenCalledOnce(); + expect(component.categoryFilter?.options[0].searched).toBeTrue(); + expect(component.model).toBeUndefined(); // Clear the input field after selection + }); + + it('should not change category filter when no item is provided', () => { + expect(component.categoryFilter?.options[0].searched).toBeFalse(); // if it is not false in the beginning we do not test anything here + const onSelectItemSpy = jest.spyOn(component, 'onSelectItem'); + + component.model = 'categoryThatIsNotDefinedAndSearchedViaEnter'; + const event = { + item: undefined, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + component.onSelectItem(event); + fixture.detectChanges(); + + expect(onSelectItemSpy).toHaveBeenCalledOnce(); + expect(component.categoryFilter?.options[0].searched).toBeFalse(); + expect(component.model).toBe('categoryThatIsNotDefinedAndSearchedViaEnter'); + }); + }); + + it('should reset all filters when button is clicked', () => { + component.categoryFilter!.options[0].searched = true; + component.categoryFilter!.options[1].searched = true; + component.typeFilter!.options[0].checked = true; + component.typeFilter!.options[1].checked = true; + component.difficultyFilter!.options[0].checked = true; + component.difficultyFilter!.options[1].checked = false; + component.achievablePoints!.filter.selectedMax = 10; + component.achievedScore!.filter.selectedMin = 10; + const resetFilterSpy = jest.spyOn(component, 'clearFilter'); + + const resetButton = fixture.debugElement.query(By.css('span[jhiTranslate="artemisApp.courseOverview.exerciseFilter.clearFilter"]')); + expect(resetButton).not.toBeNull(); + resetButton.nativeElement.click(); + + expect(resetFilterSpy).toHaveBeenCalledOnce(); + expect(component.categoryFilter!.options[0].searched).toBeFalse(); + expect(component.categoryFilter!.options[1].searched).toBeFalse(); + expect(component.typeFilter!.options[0].checked).toBeFalse(); + expect(component.typeFilter!.options[1].checked).toBeFalse(); + expect(component.difficultyFilter!.options[0].checked).toBeFalse(); + expect(component.difficultyFilter!.options[1].checked).toBeFalse(); + expect(component.achievablePoints!.filter.selectedMax).toBe(component.achievablePoints?.filter.generalMax); + expect(component.achievedScore!.filter.selectedMin).toBe(component.achievedScore?.filter.generalMin); + }); + + it('should apply filters, emit the correct sidebar data and close the modal', () => { + component.sidebarData = { + groupByCategory: true, + sidebarType: 'exercise', + groupedData: { + past: { entityData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3] }, + }, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3], + }; + component.categoryFilter!.options[0].searched = true; // must have 'category1' + component.typeFilter!.options[0].checked = true; // must be a programming exercise + component.difficultyFilter!.options[0].checked = true; // must be easy + component.achievablePoints!.filter.selectedMax = 10; + component.achievedScore!.filter.selectedMin = 10; + + const filterAppliedEmitSpy = jest.spyOn(component.filterApplied, 'emit'); + const applyFilterSpy = jest.spyOn(component, 'applyFilter'); + const closeModalSpy = jest.spyOn(component, 'closeModal'); + const applyButton = fixture.debugElement.query(By.css('button[jhiTranslate="artemisApp.courseOverview.exerciseFilter.applyFilter"]')); + expect(applyButton).not.toBeNull(); + applyButton.nativeElement.click(); + + expect(applyFilterSpy).toHaveBeenCalledOnce(); + expect(filterAppliedEmitSpy).toHaveBeenCalledOnce(); + expect(filterAppliedEmitSpy).toHaveBeenCalledWith({ + filteredSidebarData: component.sidebarData, + appliedExerciseFilters: component.exerciseFilters, + isFilterActive: true, + }); + /** only {@link EXERCISE_3} fullfills the filter options and should be emitted in the event */ + expect(component.sidebarData.ungroupedData?.length).toBe(1); + + expect(closeModalSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.helper.spec.ts b/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.helper.spec.ts new file mode 100644 index 000000000000..23ec9d99c4be --- /dev/null +++ b/src/test/javascript/spec/component/shared/exercise-filter/exercise-filter-modal.helper.spec.ts @@ -0,0 +1,333 @@ +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { DifficultyLevel, Exercise, ExerciseType } from 'app/entities/exercise.model'; +import { Result } from 'app/entities/result.model'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; +import { SidebarCardElement } from 'app/types/sidebar'; +import { + satisfiesCategoryFilter, + satisfiesDifficultyFilter, + satisfiesFilters, + satisfiesPointsFilter, + satisfiesScoreFilter, +} from 'app/shared/exercise-filter/exercise-filter-modal.helper'; +import { FilterDetails, RangeFilter } from 'app/types/exercise-filter'; + +const EXERCISE_1 = { categories: [new ExerciseCategory('category1', '#691b0b'), new ExerciseCategory('category2', '#1b97ca')], maxPoints: 10, type: ExerciseType.TEXT } as Exercise; +const EXERCISE_2 = { categories: [new ExerciseCategory('category3', '#0d3cc2'), new ExerciseCategory('category4', '#6ae8ac')], maxPoints: 5 } as Exercise; +const EXERCISE_4 = { categories: [new ExerciseCategory('category1', '#691b0b'), new ExerciseCategory('category8', '#1b97ca')], maxPoints: 10 } as Exercise; +const EXERCISE_5 = { maxPoints: 20 } as Exercise; + +const SIDEBAR_CARD_ELEMENT_1 = { + exercise: EXERCISE_1, + type: ExerciseType.TEXT, + difficulty: DifficultyLevel.HARD, + studentParticipation: { results: [{ score: 7.7 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_2 = { + exercise: EXERCISE_2, + type: ExerciseType.PROGRAMMING, + difficulty: DifficultyLevel.EASY, + studentParticipation: { results: [{ score: 82.3 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_4 = { exercise: EXERCISE_4 } as SidebarCardElement; + +/** contains duplicated type and difficulty with {@link SIDEBAR_CARD_ELEMENT_2}*/ +const SIDEBAR_CARD_ELEMENT_5 = { exercise: EXERCISE_5, type: ExerciseType.PROGRAMMING, difficulty: DifficultyLevel.EASY } as SidebarCardElement; + +describe('satisfiesDifficultyFilter', () => { + it('should return true if difficulty filter is undefined', () => { + const difficultyFilter = undefined; + + const resultItemWithDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_1, difficultyFilter); + expect(resultItemWithDifficulty).toBeTrue(); + + const resultItemWithoutDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_4, difficultyFilter); + expect(resultItemWithoutDifficulty).toBeTrue(); + }); + + it('should return true if difficulty filter is []', () => { + const difficultyFilter: DifficultyLevel[] = []; + + const resultItemWithDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_1, difficultyFilter); + expect(resultItemWithDifficulty).toBeTrue(); + + const resultItemWithoutDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_4, difficultyFilter); + expect(resultItemWithoutDifficulty).toBeTrue(); + }); + + it('should return true if difficulty is in difficulty filter', () => { + const difficultyFilter = [DifficultyLevel.HARD]; + + const resultItemWithDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_1, difficultyFilter); + expect(resultItemWithDifficulty).toBeTrue(); + }); + + it('should return false if difficulty is NOT in difficulty filter', () => { + const difficultyFilter = [DifficultyLevel.HARD]; + + const resultItemWithDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_2, difficultyFilter); + expect(resultItemWithDifficulty).toBeFalse(); + + const resultItemWithoutDifficulty = satisfiesDifficultyFilter(SIDEBAR_CARD_ELEMENT_4, difficultyFilter); + expect(resultItemWithoutDifficulty).toBeFalse(); + }); +}); + +describe('satisfiesCategoryFilter', () => { + it('should return true if difficulty filter is []', () => { + const categoryFilter: ExerciseCategory[] = []; + + const resultItemWithCategory = satisfiesCategoryFilter(SIDEBAR_CARD_ELEMENT_1, categoryFilter); + expect(resultItemWithCategory).toBeTrue(); + + const resultItemWithoutCategory = satisfiesCategoryFilter(SIDEBAR_CARD_ELEMENT_5, categoryFilter); + expect(resultItemWithoutCategory).toBeTrue(); + }); + + it('should return true category is included in difficulty filter', () => { + const categoryFilter = [new ExerciseCategory('category1', '#691b0b')]; + + const resultItemWithMatchingCategory = satisfiesCategoryFilter(SIDEBAR_CARD_ELEMENT_1, categoryFilter); + expect(resultItemWithMatchingCategory).toBeTrue(); + }); + + it('should return false if difficulty is NOT in difficulty filter', () => { + const categoryFilter = [new ExerciseCategory('notExistingCategory', '#691b0b')]; + + const resultItemWithCategory = satisfiesCategoryFilter(SIDEBAR_CARD_ELEMENT_1, categoryFilter); + expect(resultItemWithCategory).toBeFalse(); + + const resultItemWithoutCategory = satisfiesCategoryFilter(SIDEBAR_CARD_ELEMENT_5, categoryFilter); + expect(resultItemWithoutCategory).toBeFalse(); + }); +}); + +describe('satisfiesScoreFilter', () => { + it('should return true if score filter is undefined', () => { + const scoreFilter = undefined; + + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_1, true, scoreFilter); + expect(resultItemWithScore).toBeTrue(); + + const resultItemWithoutScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_4, true, scoreFilter); + expect(resultItemWithoutScore).toBeTrue(); + }); + + it('should return true if score filter is not applied', () => { + const scoreFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 0, + selectedMax: 1, + generalMin: 0, + generalMax: 1, + step: 1, + }, + }; + + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_1, false, scoreFilter); + expect(resultItemWithScore).toBeTrue(); + + const resultItemWithoutScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_4, false, scoreFilter); + expect(resultItemWithoutScore).toBeTrue(); + }); + + it('should return true if score is in score filter', () => { + const scoreFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 5, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_1, true, scoreFilter); + expect(resultItemWithScore).toBeTrue(); + }); + + it('should return false if score is NOT in score filter', () => { + const scoreFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 20, + selectedMax: 30, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_1, true, scoreFilter); + expect(resultItemWithScore).toBeFalse(); + + const resultItemWithoutScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_4, true, scoreFilter); + expect(resultItemWithoutScore).toBeFalse(); + }); + + it('should return true if score of participation is not defined (not participated) and lower bound is 0', () => { + const scoreFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 0, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_4, true, scoreFilter); + expect(resultItemWithScore).toBeTrue(); + }); + + it('should return false if score of participation is not defined (not participated) and lower bound is NOT 0', () => { + const scoreFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 1, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + const resultItemWithScore = satisfiesScoreFilter(SIDEBAR_CARD_ELEMENT_4, true, scoreFilter); + expect(resultItemWithScore).toBeFalse(); + }); +}); + +describe('satisfiesPointsFilter', () => { + it('should return true if points filter is undefined', () => { + const pointsFilter = undefined; + + const resultItemWithPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_1, true, pointsFilter); + expect(resultItemWithPoints).toBeTrue(); + + const resultItemWithoutPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_4, true, pointsFilter); + expect(resultItemWithoutPoints).toBeTrue(); + }); + + it('should return true if points filter is not applied', () => { + const pointsFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 0, + selectedMax: 1, + generalMin: 0, + generalMax: 1, + step: 1, + }, + }; + + const resultItemWithPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_1, false, pointsFilter); + expect(resultItemWithPoints).toBeTrue(); + + const resultItemWithoutPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_4, false, pointsFilter); + expect(resultItemWithoutPoints).toBeTrue(); + }); + + it('should return true if points is in points filter', () => { + const pointsFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 9, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + + const resultItemWithPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_1, true, pointsFilter); + expect(resultItemWithPoints).toBeTrue(); + }); + + it('should return false if points is NOT in points filter', () => { + const pointsFilter: RangeFilter = { + isDisplayed: true, + filter: { + selectedMin: 11, + selectedMax: 12, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }; + + const resultItemWithPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_1, true, pointsFilter); + expect(resultItemWithPoints).toBeFalse(); + + const resultItemWithoutPoints = satisfiesPointsFilter(SIDEBAR_CARD_ELEMENT_4, true, pointsFilter); + expect(resultItemWithoutPoints).toBeFalse(); + }); +}); + +describe('satisfiesFilters', () => { + it('should return true if item satisfies filters', () => { + const filter: FilterDetails = { + selectedCategories: [new ExerciseCategory('category1', '#691b0b')], + searchedTypes: [ExerciseType.TEXT], + searchedDifficulties: [DifficultyLevel.HARD], + isScoreFilterApplied: false, + isPointsFilterApplied: false, + achievedScore: { + isDisplayed: true, + filter: { + selectedMin: 5, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }, + achievablePoints: { + isDisplayed: true, + filter: { + selectedMin: 9, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }, + }; + const resultItem = satisfiesFilters(SIDEBAR_CARD_ELEMENT_1, filter); + + expect(resultItem).toBeTrue(); + }); + + it('should return false if item does not satisfy the score filter', () => { + const filter: FilterDetails = { + selectedCategories: [new ExerciseCategory('category1', '#691b0b')], + searchedTypes: [ExerciseType.TEXT], + searchedDifficulties: [DifficultyLevel.HARD], + isScoreFilterApplied: false, + isPointsFilterApplied: false, + achievedScore: { + isDisplayed: true, + filter: { + selectedMin: 69, + selectedMax: 70, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }, + achievablePoints: { + isDisplayed: true, + filter: { + selectedMin: 9, + selectedMax: 10, + generalMin: 0, + generalMax: 80, + step: 1, + }, + }, + }; + const resultItem = satisfiesFilters(SIDEBAR_CARD_ELEMENT_1, filter); + + expect(resultItem).toBeTrue(); + }); +}); diff --git a/src/test/javascript/spec/component/shared/sidebar/sidebar-accordion.component.spec.ts b/src/test/javascript/spec/component/shared/sidebar/sidebar-accordion.component.spec.ts index e1e67128974d..bcffb82bc9c1 100644 --- a/src/test/javascript/spec/component/shared/sidebar/sidebar-accordion.component.spec.ts +++ b/src/test/javascript/spec/component/shared/sidebar/sidebar-accordion.component.spec.ts @@ -63,10 +63,6 @@ describe('SidebarAccordionComponent', () => { jest.restoreAllMocks(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should toggle collapse state for a group', () => { const groupKey = 'noDate'; component.toggleGroupCategoryCollapse(groupKey); @@ -89,10 +85,20 @@ describe('SidebarAccordionComponent', () => { component.searchValue = 'test'; component.ngOnChanges(); + expect(component.expandAll).toHaveBeenCalledOnce(); + }); + + it('should call expandAll when filter is active', () => { + jest.spyOn(component, 'expandAll'); + + component.isFilterActive = true; + component.ngOnChanges(); + fixture.detectChanges(); - expect(component.expandAll).toHaveBeenCalled(); + expect(component.expandAll).toHaveBeenCalledOnce(); }); + it('should correctly call setStoredCollapseState when searchValue is cleared', () => { const expectedStateAfterClear = component.collapseState; component.searchValue = 'initial value'; @@ -106,7 +112,7 @@ describe('SidebarAccordionComponent', () => { fixture.detectChanges(); - expect(component.setStoredCollapseState).toHaveBeenCalled(); + expect(component.setStoredCollapseState).toHaveBeenCalledOnce(); expect(component.collapseState).toEqual(expectedStateAfterClear); }); diff --git a/src/test/javascript/spec/component/shared/sidebar/sidebar.component.spec.ts b/src/test/javascript/spec/component/shared/sidebar/sidebar.component.spec.ts index b809b3e3708d..55ad12ae7776 100644 --- a/src/test/javascript/spec/component/shared/sidebar/sidebar.component.spec.ts +++ b/src/test/javascript/spec/component/shared/sidebar/sidebar.component.spec.ts @@ -4,25 +4,39 @@ import { SidebarCardMediumComponent } from 'app/shared/sidebar/sidebar-card-medi import { SidebarCardItemComponent } from 'app/shared/sidebar/sidebar-card-item/sidebar-card-item.component'; import { SidebarCardDirective } from 'app/shared/sidebar/sidebar-card.directive'; import { ArtemisTestModule } from '../../../test.module'; -import { DebugElement } from '@angular/core'; import { SearchFilterPipe } from 'app/shared/pipes/search-filter.pipe'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; - import { By } from '@angular/platform-browser'; -import { MockModule, MockPipe } from 'ng-mocks'; +import { MockComponent, MockDirective, MockModule, MockPipe, MockProvider } from 'ng-mocks'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MockRouterLinkDirective } from '../../../helpers/mocks/directive/mock-router-link.directive'; import { RouterModule } from '@angular/router'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { SidebarCardElement, SidebarData } from 'app/types/sidebar'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { ExerciseFilterModalComponent } from 'app/shared/exercise-filter/exercise-filter-modal.component'; +import { ExerciseFilterResults } from 'app/types/exercise-filter'; +import { EventEmitter } from '@angular/core'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { ExerciseType } from 'app/entities/exercise.model'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; describe('SidebarComponent', () => { let component: SidebarComponent; let fixture: ComponentFixture; - let debugElement: DebugElement; + let modalService: NgbModal; beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MockModule(FormsModule), MockModule(ReactiveFormsModule), MockModule(RouterModule)], + imports: [ + ArtemisTestModule, + MockModule(FormsModule), + MockModule(ReactiveFormsModule), + MockModule(RouterModule), + MockDirective(TranslateDirective), + MockComponent(ExerciseFilterModalComponent), + ], declarations: [ SidebarComponent, SidebarCardMediumComponent, @@ -33,18 +47,19 @@ describe('SidebarComponent', () => { MockPipe(ArtemisTranslatePipe), MockRouterLinkDirective, ], + providers: [MockProvider(NgbModal)], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(SidebarComponent); component = fixture.componentInstance; - debugElement = fixture.debugElement; - fixture.detectChanges(); - }); + modalService = TestBed.inject(NgbModal); - it('should create', () => { - expect(component).toBeTruthy(); + component.sidebarData = { + sidebarType: 'default', + } as SidebarData; + fixture.detectChanges(); }); it('should filter sidebar items based on search criteria', () => { @@ -71,11 +86,16 @@ describe('SidebarComponent', () => { groupByCategory: true, ungroupedData: [], }; - fixture.detectChanges(); + component.sidebarDataBeforeFiltering = { + groupByCategory: true, + ungroupedData: [] as SidebarCardElement[], + }; + + const noDataMessageElement = fixture.debugElement.query(By.css('.scrollable-item-content')).nativeElement; - const noDataMessageElement = debugElement.query(By.css('[jhiTranslate$=noDataFound]')); expect(noDataMessageElement).toBeTruthy(); - expect(noDataMessageElement.nativeElement.getAttribute('jhiTranslate')).toBe('artemisApp.courseOverview.general.noDataFound'); + // unfortunately the translation key is cut off in debug mode that seems to be used for testing + expect(noDataMessageElement.getAttribute('ng-reflect-jhi-translate')).toBe('artemisApp.courseOverview.gene'); }); it('should give the correct size for exercises', () => { @@ -109,4 +129,118 @@ describe('SidebarComponent', () => { const size = component.getSize(); expect(size).toBe('M'); }); + + describe('openFilterExercisesLink', () => { + const FILTER_LINK_SELECTOR = '.text-primary a'; + + it('should display the filter link', () => { + component.showFilter = true; + fixture.detectChanges(); + + const filterLink = fixture.debugElement.query(By.css(FILTER_LINK_SELECTOR)); + + expect(filterLink).toBeTruthy(); + }); + + it('should NOT display the filter link when sidebarType is NOT exercise', () => { + const filterLink = fixture.debugElement.query(By.css(FILTER_LINK_SELECTOR)); + + expect(filterLink).toBeFalsy(); + }); + + it('should open modal on click with initialized filters', () => { + component.showFilter = true; + fixture.detectChanges(); + const filterAppliedMock = new EventEmitter(); + const mockReturnValue = { + result: Promise.resolve({}), + componentInstance: { + sidebarData: {}, + exerciseFilters: {}, + filterApplied: filterAppliedMock, + }, + } as NgbModalRef; + const openModalSpy = jest.spyOn(modalService, 'open').mockReturnValue(mockReturnValue); + const openFilterExercisesDialogSpy = jest.spyOn(component, 'openFilterExercisesDialog'); + const initFilterOptionsSpy = jest.spyOn(component, 'initializeFilterOptions'); + + const filterLink = fixture.debugElement.query(By.css(FILTER_LINK_SELECTOR)).nativeElement; + filterLink.click(); + + expect(initFilterOptionsSpy).toHaveBeenCalledOnce(); + expect(openFilterExercisesDialogSpy).toHaveBeenCalledOnce(); + expect(openModalSpy).toHaveBeenCalledWith(ExerciseFilterModalComponent, { animation: true, backdrop: 'static', size: 'lg' }); + }); + }); + + describe('openFilterExercisesDialog', () => { + it('should subscribe to filterApplied from modal', () => { + const filterAppliedEmitter = new EventEmitter(); + const mockModalRef: Partial = { + componentInstance: { + filterApplied: filterAppliedEmitter, + }, + }; + const openSpy = jest.spyOn(modalService, 'open').mockReturnValue(mockModalRef as NgbModalRef); + const subscribeSpy = jest.spyOn(filterAppliedEmitter, 'subscribe'); + + component.openFilterExercisesDialog(); + + expect(openSpy).toHaveBeenCalledOnce(); + expect(subscribeSpy).toHaveBeenCalledOnce(); + }); + + it('should update variables correctly when filterApplied is emitted', () => { + const filterAppliedEmitter = new EventEmitter(); + const mockModalRef: Partial = { + componentInstance: { + filterApplied: filterAppliedEmitter, + }, + }; + jest.spyOn(modalService, 'open').mockReturnValue(mockModalRef as NgbModalRef); + + const mockFilterResults: ExerciseFilterResults = { + filteredSidebarData: { + sidebarType: 'exercise', + groupByCategory: true, + ungroupedData: [{ title: 'test sidebar card element' } as SidebarCardElement], + groupedData: { + testGroup: { + entityData: [{ title: 'test group element' } as SidebarCardElement], + }, + }, + }, + appliedExerciseFilters: { + categoryFilter: { + isDisplayed: true, + options: [ + { + category: new ExerciseCategory('test', undefined), + searched: true, + }, + ], + }, + exerciseTypesFilter: { + isDisplayed: true, + options: [ + { + name: 'testType', + value: ExerciseType.PROGRAMMING, + checked: true, + icon: 'testIcon' as unknown as IconProp, + }, + ], + }, + }, + isFilterActive: true, + }; + + component.openFilterExercisesDialog(); + filterAppliedEmitter.emit(mockFilterResults); + + expect(component.sidebarData).toEqual(mockFilterResults.filteredSidebarData); + expect(component.exerciseFilters).toEqual(mockFilterResults.appliedExerciseFilters); + expect(component.isFilterActive).toBeTrue(); + }); + }); }); diff --git a/src/test/javascript/spec/component/shared/sidebar/sidebar.helper.spec.ts b/src/test/javascript/spec/component/shared/sidebar/sidebar.helper.spec.ts new file mode 100644 index 000000000000..a29596e12685 --- /dev/null +++ b/src/test/javascript/spec/component/shared/sidebar/sidebar.helper.spec.ts @@ -0,0 +1,329 @@ +import { + getAchievablePointsAndAchievedScoreFilterOptions, + getExerciseCategoryFilterOptions, + getExerciseDifficultyFilterOptions, + getExerciseTypeFilterOptions, +} from 'app/shared/sidebar/sidebar.helper'; +import { SidebarCardElement, SidebarData } from 'app/types/sidebar'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { DifficultyLevel, Exercise, ExerciseType, getIcon } from 'app/entities/exercise.model'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; +import { Result } from 'app/entities/result.model'; + +const EXERCISE_1 = { categories: [new ExerciseCategory('category1', '#691b0b'), new ExerciseCategory('category2', '#1b97ca')], maxPoints: 10 } as Exercise; +const EXERCISE_2 = { categories: [new ExerciseCategory('category3', '#0d3cc2'), new ExerciseCategory('category4', '#6ae8ac')], maxPoints: 5 } as Exercise; +const EXERCISE_3 = { categories: [new ExerciseCategory('category5', '#691b0b')], maxPoints: 2 } as Exercise; + +/** contains 1 duplicate categories and maxPoints with {@link EXERCISE_1} */ +const EXERCISE_4 = { categories: [new ExerciseCategory('category1', '#691b0b'), new ExerciseCategory('category8', '#1b97ca')], maxPoints: 10 } as Exercise; +const EXERCISE_5 = { categories: [] as ExerciseCategory[], maxPoints: 20 } as Exercise; + +const SIDEBAR_CARD_ELEMENT_1 = { + exercise: EXERCISE_1, + type: ExerciseType.TEXT, + difficulty: DifficultyLevel.HARD, + studentParticipation: { results: [{ score: 7.7 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_2 = { + exercise: EXERCISE_2, + type: ExerciseType.PROGRAMMING, + difficulty: DifficultyLevel.EASY, + studentParticipation: { results: [{ score: 82.3 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_3 = { + exercise: EXERCISE_3, + type: ExerciseType.QUIZ, + difficulty: DifficultyLevel.MEDIUM, + studentParticipation: { results: [{ score: 44.5 } as Result] } as StudentParticipation, +} as SidebarCardElement; +const SIDEBAR_CARD_ELEMENT_4 = { exercise: EXERCISE_4 } as SidebarCardElement; + +/** contains duplicated type and difficulty with {@link SIDEBAR_CARD_ELEMENT_2}*/ +const SIDEBAR_CARD_ELEMENT_5 = { exercise: EXERCISE_5, type: ExerciseType.PROGRAMMING, difficulty: DifficultyLevel.EASY } as SidebarCardElement; + +describe('getExerciseCategoryFilterOptions', () => { + it('should return all exercise categories', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + groupedData: { + dueSoon: { + entityData: [], + }, + noDate: { + entityData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2], + }, + past: { + entityData: [SIDEBAR_CARD_ELEMENT_3], + }, + }, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3], + }; + + const exerciseCategories = getExerciseCategoryFilterOptions(sidebarData, undefined); + expect(exerciseCategories).toEqual({ + isDisplayed: true, + options: [ + { category: new ExerciseCategory('category1', '#691b0b'), searched: false }, + { category: new ExerciseCategory('category2', '#1b97ca'), searched: false }, + { category: new ExerciseCategory('category3', '#0d3cc2'), searched: false }, + { category: new ExerciseCategory('category4', '#6ae8ac'), searched: false }, + { category: new ExerciseCategory('category5', '#691b0b'), searched: false }, + ], + }); + }); + + it('should filter duplicates', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_4], + }; + + const exerciseCategories = getExerciseCategoryFilterOptions(sidebarData, undefined); + expect(exerciseCategories).toEqual({ + isDisplayed: true, + options: [ + { category: new ExerciseCategory('category1', '#691b0b'), searched: false }, + { category: new ExerciseCategory('category2', '#1b97ca'), searched: false }, + { category: new ExerciseCategory('category8', '#1b97ca'), searched: false }, + ], + }); + }); + + it('should sort categories alphanumerical', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_1], + }; + + const exerciseCategories = getExerciseCategoryFilterOptions(sidebarData, undefined); + expect(exerciseCategories).toEqual({ + isDisplayed: true, + options: [ + { category: new ExerciseCategory('category1', '#691b0b'), searched: false }, + { category: new ExerciseCategory('category2', '#1b97ca'), searched: false }, + { category: new ExerciseCategory('category3', '#0d3cc2'), searched: false }, + { category: new ExerciseCategory('category4', '#6ae8ac'), searched: false }, + ], + }); + }); + + it('should not be displayed if no categories are available', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_5], + }; + + const exerciseCategories = getExerciseCategoryFilterOptions(sidebarData, undefined); + expect(exerciseCategories).toEqual({ + isDisplayed: false, + options: [], + }); + }); + + it('should directly return if already initialized', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2], + }; + + const exerciseCategories = getExerciseCategoryFilterOptions(sidebarData, { + categoryFilter: { + isDisplayed: false, + options: [], + }, + }); + expect(exerciseCategories).toEqual({ + isDisplayed: false, + options: [], + }); + }); +}); + +describe('getExerciseTypeFilterOptions', () => { + it('should return present exercise types and sort them properly (same order as instructor creation)', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3, SIDEBAR_CARD_ELEMENT_4, SIDEBAR_CARD_ELEMENT_5], + }; + + const exerciseTypesFilter = getExerciseTypeFilterOptions(sidebarData, undefined); + expect(exerciseTypesFilter).toEqual({ + isDisplayed: true, + options: [ + { name: 'artemisApp.courseStatistics.programming', value: ExerciseType.PROGRAMMING, checked: false, icon: getIcon(ExerciseType.PROGRAMMING) }, + { name: 'artemisApp.courseStatistics.quiz', value: ExerciseType.QUIZ, checked: false, icon: getIcon(ExerciseType.QUIZ) }, + { name: 'artemisApp.courseStatistics.text', value: ExerciseType.TEXT, checked: false, icon: getIcon(ExerciseType.TEXT) }, + ], + }); + }); + + it('should not contain duplicates', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_5], + }; + + const exerciseTypesFilter = getExerciseTypeFilterOptions(sidebarData, undefined); + expect(exerciseTypesFilter).toEqual({ + isDisplayed: true, + options: [ + { name: 'artemisApp.courseStatistics.programming', value: ExerciseType.PROGRAMMING, checked: false, icon: getIcon(ExerciseType.PROGRAMMING) }, + { name: 'artemisApp.courseStatistics.text', value: ExerciseType.TEXT, checked: false, icon: getIcon(ExerciseType.TEXT) }, + ], + }); + }); + + it('should directly return if already initialized', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2], + }; + + const exerciseTypesFilter = getExerciseTypeFilterOptions(sidebarData, { + exerciseTypesFilter: { + isDisplayed: false, + options: [], + }, + }); + expect(exerciseTypesFilter).toEqual({ + isDisplayed: false, + options: [], + }); + }); +}); + +describe('getExerciseDifficultyFilterOptions', () => { + it('should return present exercise difficulties and sort them ascending', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3, SIDEBAR_CARD_ELEMENT_4, SIDEBAR_CARD_ELEMENT_5], + }; + + const difficultyFilter = getExerciseDifficultyFilterOptions(sidebarData, undefined); + expect(difficultyFilter).toEqual({ + isDisplayed: true, + options: [ + { name: 'artemisApp.exercise.easy', value: DifficultyLevel.EASY, checked: false }, + { name: 'artemisApp.exercise.medium', value: DifficultyLevel.MEDIUM, checked: false }, + { name: 'artemisApp.exercise.hard', value: DifficultyLevel.HARD, checked: false }, + ], + }); + }); + + it('should not contain duplicates', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_5], + }; + + const difficultyFilter = getExerciseDifficultyFilterOptions(sidebarData, undefined); + expect(difficultyFilter).toEqual({ + isDisplayed: true, + options: [ + { name: 'artemisApp.exercise.easy', value: DifficultyLevel.EASY, checked: false }, + { name: 'artemisApp.exercise.hard', value: DifficultyLevel.HARD, checked: false }, + ], + }); + }); + + it('should directly return if already initialized', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2], + }; + + const difficultyFilter = getExerciseDifficultyFilterOptions(sidebarData, { + difficultyFilter: { + isDisplayed: false, + options: [], + }, + }); + expect(difficultyFilter).toEqual({ + isDisplayed: false, + options: [], + }); + }); +}); + +describe('getAchievablePointsAndAchievedScoreFilterOptions', () => { + const expectedFilterForFirstThreePresentExercises = { + achievablePoints: { + isDisplayed: true, + filter: { + generalMin: 2, + generalMax: 10, + selectedMin: 2, + selectedMax: 10, + step: 1, + }, + }, + achievedScore: { + isDisplayed: true, + filter: { + generalMax: 85, + generalMin: 5, + selectedMax: 85, + selectedMin: 5, + step: 5, + }, + }, + }; + + it('should return present exercise point and score range', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1, SIDEBAR_CARD_ELEMENT_2, SIDEBAR_CARD_ELEMENT_3], + }; + + const scoreAndPointsFilterOptions = getAchievablePointsAndAchievedScoreFilterOptions(sidebarData, undefined); + expect(scoreAndPointsFilterOptions).toEqual(expectedFilterForFirstThreePresentExercises); + }); + + it('should set scores filter to not displayed if no scores are present', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_4, SIDEBAR_CARD_ELEMENT_5], + }; + + const scoreAndPointsFilterOptions = getAchievablePointsAndAchievedScoreFilterOptions(sidebarData, undefined); + + expect(scoreAndPointsFilterOptions).toEqual({ + achievablePoints: { + isDisplayed: true, + filter: { + generalMax: 20, + generalMin: 10, + selectedMax: 20, + selectedMin: 10, + step: 1, + }, + }, + achievedScore: { + isDisplayed: false, + filter: { + generalMax: -Infinity, + generalMin: Infinity, + selectedMax: -Infinity, + selectedMin: Infinity, + step: 1, + }, + }, + }); + }); + + it('should directly return if already initialized and filters are not applied', () => { + const sidebarData: SidebarData = { + groupByCategory: true, + ungroupedData: [SIDEBAR_CARD_ELEMENT_1], + }; + + const scoreAndPointsFilterOptions = getAchievablePointsAndAchievedScoreFilterOptions(sidebarData, { + achievablePoints: expectedFilterForFirstThreePresentExercises.achievablePoints, + achievedScore: expectedFilterForFirstThreePresentExercises.achievedScore, + }); + expect(scoreAndPointsFilterOptions).toEqual({ + achievablePoints: expectedFilterForFirstThreePresentExercises.achievablePoints, + achievedScore: expectedFilterForFirstThreePresentExercises.achievedScore, + }); + }); +}); diff --git a/src/test/javascript/spec/component/statistics/statistics-average-score-graph.component.spec.ts b/src/test/javascript/spec/component/statistics/statistics-average-score-graph.component.spec.ts index e850210ee35e..2626dffc45e1 100644 --- a/src/test/javascript/spec/component/statistics/statistics-average-score-graph.component.spec.ts +++ b/src/test/javascript/spec/component/statistics/statistics-average-score-graph.component.spec.ts @@ -12,6 +12,7 @@ import { ArtemisNavigationUtilService } from 'app/utils/navigation.utils'; import { ChartExerciseTypeFilter } from 'app/shared/chart/chart-exercise-type-filter'; import { ChartCategoryFilter } from 'app/shared/chart/chart-category-filter'; import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; describe('StatisticsAverageScoreGraphComponent', () => { let fixture: ComponentFixture; @@ -28,35 +29,35 @@ describe('StatisticsAverageScoreGraphComponent', () => { exerciseName: 'FacadePattern', averageScore: 0, exerciseType: ExerciseType.TEXT, - categories: [{ color: '#347aeb', category: 'structural pattern' }], + categories: [new ExerciseCategory('structural pattern', '#347aeb')], }; const exercise2 = { exerciseId: 2, exerciseName: 'BridgePattern', averageScore: 20, exerciseType: ExerciseType.PROGRAMMING, - categories: [{ color: '#347aeb', category: 'structural pattern' }], + categories: [new ExerciseCategory('structural pattern', '#347aeb')], }; const exercise3 = { exerciseId: 3, exerciseName: 'VisitorPattern', averageScore: 25, exerciseType: ExerciseType.FILE_UPLOAD, - categories: [{ color: '#c034eb', category: 'behavioral pattern' }], + categories: [new ExerciseCategory('behavioral pattern', '#c034eb')], }; const exercise4 = { exerciseId: 4, exerciseName: 'AdapterPattern', averageScore: 35, exerciseType: ExerciseType.QUIZ, - categories: [{ color: '#347aeb', category: 'structural pattern' }], + categories: [new ExerciseCategory('structural pattern', '#347aeb')], }; const exercise5 = { exerciseId: 5, exerciseName: 'ProxyPattern', averageScore: 40, exerciseType: ExerciseType.MODELING, - categories: [{ color: '#347aeb', category: 'structural pattern' }], + categories: [new ExerciseCategory('structural pattern', '#347aeb')], }; const exercise6 = { exerciseId: 6, exerciseName: 'BuilderPattern', averageScore: 50, exerciseType: ExerciseType.QUIZ }; const exercise7 = { exerciseId: 7, exerciseName: 'BehaviouralPattern', averageScore: 55, exerciseType: ExerciseType.PROGRAMMING }; diff --git a/src/test/javascript/spec/component/text-exercise/text-exercise-update.component.spec.ts b/src/test/javascript/spec/component/text-exercise/text-exercise-update.component.spec.ts index aa0f2c889832..88eff4e9d26f 100644 --- a/src/test/javascript/spec/component/text-exercise/text-exercise-update.component.spec.ts +++ b/src/test/javascript/spec/component/text-exercise/text-exercise-update.component.spec.ts @@ -23,6 +23,7 @@ import { NgModel } from '@angular/forms'; import { ExerciseTitleChannelNameComponent } from 'app/exercises/shared/exercise-title-channel-name/exercise-title-channel-name.component'; import { ExerciseUpdatePlagiarismComponent } from 'app/exercises/shared/plagiarism/exercise-update-plagiarism/exercise-update-plagiarism.component'; import { TeamConfigFormGroupComponent } from 'app/exercises/shared/team-config-form-group/team-config-form-group.component'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; describe('TextExercise Management Update Component', () => { let comp: TextExerciseUpdateComponent; @@ -363,7 +364,7 @@ describe('TextExercise Management Update Component', () => { it('should updateCategories properly by making category available for selection again when removing it', () => { comp.textExercise = new TextExercise(undefined, undefined); comp.exerciseCategories = []; - const newCategories = [{ category: 'Easy' }, { category: 'Hard' }]; + const newCategories = [new ExerciseCategory('Easy', undefined), new ExerciseCategory('Hard', undefined)]; comp.updateCategories(newCategories); diff --git a/src/test/javascript/spec/component/tutorial-groups/course-tutorial-groups/course-tutorial-groups.component.spec.ts b/src/test/javascript/spec/component/tutorial-groups/course-tutorial-groups/course-tutorial-groups.component.spec.ts index 7a722cf69505..cf60468091e9 100644 --- a/src/test/javascript/spec/component/tutorial-groups/course-tutorial-groups/course-tutorial-groups.component.spec.ts +++ b/src/test/javascript/spec/component/tutorial-groups/course-tutorial-groups/course-tutorial-groups.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TutorialGroup } from 'app/entities/tutorial-group/tutorial-group.model'; import { TutorialGroupsService } from 'app/course/tutorial-groups/services/tutorial-groups.service'; import { MockRouter } from '../../../helpers/mocks/mock-router'; -import { MockModule, MockPipe, MockProvider } from 'ng-mocks'; +import { MockDirective, MockModule, MockPipe, MockProvider } from 'ng-mocks'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { AlertService } from 'app/core/util/alert.service'; import { ActivatedRoute, Router, RouterModule, convertToParamMap } from '@angular/router'; @@ -18,6 +18,7 @@ import { SidebarComponent } from 'app/shared/sidebar/sidebar.component'; import { SearchFilterPipe } from 'app/shared/pipes/search-filter.pipe'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; describe('CourseTutorialGroupsComponent', () => { let fixture: ComponentFixture; @@ -32,7 +33,7 @@ describe('CourseTutorialGroupsComponent', () => { router.navigate.mockImplementation(() => Promise.resolve(true)); TestBed.configureTestingModule({ - imports: [ArtemisTestModule, RouterModule, MockModule(FormsModule), MockModule(ReactiveFormsModule)], + imports: [ArtemisTestModule, RouterModule, MockModule(FormsModule), MockModule(ReactiveFormsModule), MockDirective(TranslateDirective)], declarations: [CourseTutorialGroupsComponent, MockPipe(ArtemisTranslatePipe), SidebarComponent, SearchFilterComponent, MockPipe(SearchFilterPipe)], providers: [ MockProvider(TutorialGroupsService), diff --git a/src/test/javascript/spec/entities/exercise-category.model.spec.ts b/src/test/javascript/spec/entities/exercise-category.model.spec.ts new file mode 100644 index 000000000000..2beba4b62f40 --- /dev/null +++ b/src/test/javascript/spec/entities/exercise-category.model.spec.ts @@ -0,0 +1,49 @@ +import { ExerciseCategory } from 'app/entities/exercise-category.model'; + +describe('ExerciseCategory', () => { + describe('equals', () => { + it('should return true if the two exercise categories are equal', () => { + const exerciseCategory1 = new ExerciseCategory('Category 1', 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 1', 'red'); + + expect(exerciseCategory1.equals(exerciseCategory2)).toBeTruthy(); + }); + + it('should return false if the two exercise categories are not equal', () => { + const exerciseCategory1 = new ExerciseCategory('Category 1', 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 2', 'blue'); + + expect(exerciseCategory1.equals(exerciseCategory2)).toBeFalsy(); + }); + }); + + describe('compare', () => { + it("should return 0 if the two exercise categories' display text is the same", () => { + const exerciseCategory1 = new ExerciseCategory('Category 1', 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 1', 'blue'); + + expect(exerciseCategory1.compare(exerciseCategory2)).toBe(0); + }); + + it("should return -1 if the first exercise category's display text is smaller than the second exercise category's display text", () => { + const exerciseCategory1 = new ExerciseCategory('Category 1', 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 2', 'blue'); + + expect(exerciseCategory1.compare(exerciseCategory2)).toBe(-1); + }); + + it("should return 1 if the first exercise category's display text is larger than the second exercise category's display text", () => { + const exerciseCategory1 = new ExerciseCategory('Category 2', 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 1', 'blue'); + + expect(exerciseCategory1.compare(exerciseCategory2)).toBe(1); + }); + + it('should return -1 if the first exercise category is undefined', () => { + const exerciseCategory1 = new ExerciseCategory(undefined, 'red'); + const exerciseCategory2 = new ExerciseCategory('Category 1', 'blue'); + + expect(exerciseCategory1.compare(exerciseCategory2)).toBe(-1); + }); + }); +}); diff --git a/src/test/javascript/spec/service/modeling-exercise.service.spec.ts b/src/test/javascript/spec/service/modeling-exercise.service.spec.ts index e3227f1b718f..5fdfe0efe181 100644 --- a/src/test/javascript/spec/service/modeling-exercise.service.spec.ts +++ b/src/test/javascript/spec/service/modeling-exercise.service.spec.ts @@ -22,8 +22,8 @@ describe('ModelingExercise Service', () => { let httpMock: HttpTestingController; let elemDefault: ModelingExercise; let plagiarismResult: ModelingPlagiarismResult; - const category = { color: 'red', category: 'testCategory' } as ExerciseCategory; - const categories = [JSON.stringify(category) as ExerciseCategory]; + const category = new ExerciseCategory('testCategory', 'red'); + const categories = [JSON.stringify(category) as unknown as ExerciseCategory] as ExerciseCategory[]; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], diff --git a/src/test/javascript/spec/util/shared/utils.spec.ts b/src/test/javascript/spec/util/shared/utils.spec.ts index 296e7ba1fabc..b20604e27615 100644 --- a/src/test/javascript/spec/util/shared/utils.spec.ts +++ b/src/test/javascript/spec/util/shared/utils.spec.ts @@ -4,6 +4,7 @@ import { isExamExercise, round, roundScorePercentSpecifiedByCourseSettings, + roundToNextMultiple, roundValueSpecifiedByCourseSettings, stringifyIgnoringFields, } from 'app/shared/util/utils'; @@ -119,3 +120,25 @@ describe('isExamExercise', () => { expect(isExamExerciseResult).toBeFalse(); }); }); + +describe('roundUpToNextMultiple', () => { + it('should round up to multiple of 5 if value is closer to lower multiple', () => { + expect(roundToNextMultiple(21, 5, true)).toBe(25); + }); + + it('should round up to multiple of 5 if value is right underneath next multiple', () => { + expect(roundToNextMultiple(24.8, 5, true)).toBe(25); + }); + + it('should round down to multiple of 5 if value is over next multiple', () => { + expect(roundToNextMultiple(24.8, 5, false)).toBe(20); + }); + + it('should return value is it is a multiple', () => { + expect(roundToNextMultiple(25, 5, true)).toBe(25); + }); + + it('should round up to multiple of 1', () => { + expect(roundToNextMultiple(8.2, 1, true)).toBe(9); + }); +});