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 @@
+
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 (showFilter) {
+
+ }
}
@if (!sidebarData?.ungroupedData || !(sidebarData?.ungroupedData | searchFilter: ['title', 'type'] : searchValue)?.length) {
0 && sidebarData.ungroupedData?.length === 0
+ ? 'artemisApp.courseOverview.general.noElementFoundWithAppliedFilter'
+ : 'artemisApp.courseOverview.general.noDataFound'
+ "
class="mt-2 text-center scrollable-item-content"
>
} @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);
+ });
+});