Skip to content

Commit

Permalink
General: Add filter for exercises (#8858)
Browse files Browse the repository at this point in the history
  • Loading branch information
florian-glombik authored and MichaelOwenDyer committed Sep 3, 2024
1 parent cd2b3a8 commit 5298910
Show file tree
Hide file tree
Showing 50 changed files with 2,518 additions and 90 deletions.
26 changes: 25 additions & 1 deletion src/main/webapp/app/entities/exercise-category.model.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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);
});
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div class="d-flex justify-content-between gap-3 horizontal-scroll">
@if (course) {
<div [ngClass]="{ 'sidebar-collapsed': isCollapsed }">
<jhi-sidebar [itemSelected]="exerciseSelected" [courseId]="courseId" [sidebarData]="sidebarData" [collapseState]="DEFAULT_COLLAPSE_STATE" />
<jhi-sidebar [itemSelected]="exerciseSelected" [courseId]="courseId" [sidebarData]="sidebarData" [collapseState]="DEFAULT_COLLAPSE_STATE" [showFilter]="true" />
</div>

@if (exerciseSelected) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'];

Expand Down Expand Up @@ -44,8 +43,7 @@ export class CategorySelectorComponent implements OnChanges {
separatorKeysCodes = [ENTER, COMMA, TAB];
categoryCtrl = new FormControl<string | undefined>(undefined);

// Icons
faTimes = faTimes;
readonly faTimes = faTimes;

ngOnChanges() {
this.uniqueCategoriesForAutocomplete = this.categoryCtrl.valueChanges.pipe(
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ export class ResetRepoButtonComponent implements OnInit {

beforeIndividualDueDate: boolean;

// Icons
faBackward = faBackward;
readonly faBackward = faBackward;

constructor(
private participationService: ParticipationService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<h4 class="fw-medium">
<div class="badge me-1" [ngStyle]="{ backgroundColor: category.color }" [ngClass]="'category-' + fontSize">
{{ category.category }}
@if (displayRemoveButton) {
<button class="remove-button" (click)="onClick()">
<fa-icon [icon]="faTimes" class="category-chip-remove" />
</button>
}
</div>
</h4>
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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';
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ <h4 class="fw-medium">
</h4>
}
@for (category of exercise.categories; track category) {
<h4 class="fw-medium">
<span class="badge" [ngStyle]="{ backgroundColor: category.color }">{{ category.category }}</span>
</h4>
<jhi-custom-exercise-category-badge [category]="category" />
}
@if (exercise.difficulty && showTags.difficulty) {
<h4 class="fw-medium">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<form name="exerciseFilterForm" (ngSubmit)="applyFilter()">
<div class="modal-header">
<h4 class="modal-title">
<fa-icon [icon]="faFilter" />
<span jhiTranslate="artemisApp.courseOverview.exerciseFilter.modalTitle"></span>
</h4>
<button type="button" class="btn-close" (click)="closeModal()"></button>
</div>
<div class="modal-body">
<div class="form-group">
@if (noFiltersAvailable) {
<div>
<span jhiTranslate="artemisApp.courseOverview.exerciseFilter.noFilterAvailable"></span>
</div>
}

@if (selectableCategoryOptions.length || selectedCategoryOptions.length) {
<label for="exercise-categories-filter-selection" class="form-control-label h6" jhiTranslate="artemisApp.exercise.categories"></label>

<input
id="exercise-categories-filter-selection"
type="text"
class="form-control mb-2"
name="category-filter-selection"
[(ngModel)]="model"
[ngbTypeahead]="search"
[placeholder]="
(!selectableCategoryOptions.length ? 'artemisApp.courseOverview.exerciseFilter.noMoreOptions' : 'artemisApp.exercise.selectCategories') | artemisTranslate
"
(focus)="focus$.next($any($event).target.value)"
(click)="click$.next($any($event).target.value)"
#categoriesFilterSelection="ngbTypeahead"
(selectItem)="onSelectItem($event)"
[resultFormatter]="resultFormatter"
(keydown.enter)="onSelectItem($event)"
[disabled]="!selectableCategoryOptions.length"
/>

<div class="row">
<div class="d-flex flex-row flex-wrap">
@for (categoryFilterOption of selectedCategoryOptions; track categoryFilterOption) {
<div class="p-1">
<jhi-custom-exercise-category-badge
[category]="categoryFilterOption.category"
[displayRemoveButton]="true"
[onClick]="removeItem(categoryFilterOption)"
[fontSize]="'small'"
/>
</div>
}
</div>
</div>
}
</div>

@if (typeFilter?.isDisplayed) {
<div class="form-group">
<label for="exercise-type-filter-selection" class="form-control-label h6" jhiTranslate="artemisApp.ratingList.exerciseType"></label>
<div id="exercise-type-filter-selection">
@for (typeFilter of typeFilter!.options; track typeFilter) {
<div class="form-check form-check-inline no-left-margin-padding">
<label class="pointer">
<input type="checkbox" [(ngModel)]="typeFilter.checked" [name]="typeFilter.value" class="pointer" />
<fa-icon [icon]="typeFilter.icon" class="ms-2" />
<span [jhiTranslate]="typeFilter.name" class="ms-1"></span>
</label>
</div>
}
</div>
</div>
}

<!-- <div class="form-group">-->
<!-- <label class="form-control-label h6" jhiTranslate="artemisApp.courseOverview.exerciseFilter.dueDateRange"> </label>-->
<!-- TODO: Implement Date Range Picker (follow up PR @FlorianGlombik)-->
<!-- </div>-->

<!-- On typescript version 5.5.4 the explicit check for difficultyFilter is needed,
as otherwise typescript thinks it could be undefined in the loop (the client would not start in this case) -->
@if (difficultyFilter?.isDisplayed && difficultyFilter) {
<div class="form-group">
<label for="difficulty-filter-selection" class="form-control-label h6" jhiTranslate="artemisApp.exercise.difficulty"></label>
<div id="difficulty-filter-selection">
@for (difficultyFilterOption of difficultyFilter.options; track difficultyFilterOption) {
<div class="form-check form-check-inline no-left-margin-padding">
<label class="pointer">
<input type="checkbox" [(ngModel)]="difficultyFilterOption.checked" [name]="difficultyFilterOption.value" class="pointer" />
<span [jhiTranslate]="difficultyFilterOption.name" class="ms-1"></span>
</label>
</div>
}
</div>
</div>
}

<!-- On typescript version 5.5.4 the explicit check for achievedScore is needed,
as otherwise typescript thinks achievedScore could be undefined (the client would not start in this case) -->
@if (achievedScore && achievedScore?.isDisplayed) {
<div class="form-group">
<label class="form-control-label h6" for="achieved-score-range-slider" jhiTranslate="artemisApp.courseOverview.exerciseFilter.achievedScore"></label>
<jhi-range-slider
id="achieved-score-range-slider"
[generalMinValue]="achievedScore.filter.generalMin"
[generalMaxValue]="achievedScore.filter.generalMax"
[(selectedMinValue)]="achievedScore.filter.selectedMin"
[(selectedMaxValue)]="achievedScore.filter.selectedMax"
[step]="achievedScore.filter.step"
[labelSymbol]="'%'"
/>
</div>
}

<!-- On typescript version 5.5.4 the explicit check for achievablePoints is needed,
as otherwise typescript thinks achievablePoints could be undefined (the client would not start in this case) -->
@if (achievablePoints && achievablePoints.isDisplayed) {
<div class="form-group">
<label class="form-control-label h6" for="achieved-points-range-slider" jhiTranslate="artemisApp.courseOverview.exerciseFilter.achievablePoints"></label>
<jhi-range-slider
id="achieved-points-range-slider"
[generalMinValue]="achievablePoints.filter.generalMin"
[generalMaxValue]="achievablePoints.filter.generalMax"
[(selectedMinValue)]="achievablePoints.filter.selectedMin"
[(selectedMaxValue)]="achievablePoints.filter.selectedMax"
[step]="achievablePoints.filter.step"
/>
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="clearFilter()">
<span jhiTranslate="artemisApp.courseOverview.exerciseFilter.clearFilter"></span>
</button>

<div class="ms-auto">
<button type="button" class="btn btn-secondary" (click)="closeModal()" jhiTranslate="entity.action.cancel" aria-label="Close"></button>
<button
type="button"
class="btn btn-primary"
(click)="applyFilter()"
jhiTranslate="artemisApp.courseOverview.exerciseFilter.applyFilter"
[disabled]="noFiltersAvailable"
></button>
</div>
</div>
</form>
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 5298910

Please sign in to comment.