Skip to content

Commit

Permalink
Add result filter slider (#2283)
Browse files Browse the repository at this point in the history
* range slider

* fix slider style

* cleanup

* Support chrome

* debounce onRangeChange callback

---------

Co-authored-by: Johannes Wolf <janno42@posteo.de>
Co-authored-by: Richard Ebeling <dev@richardebeling.de>
  • Loading branch information
3 people authored Oct 14, 2024
1 parent ae448e7 commit 2da6090
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 26 deletions.
20 changes: 17 additions & 3 deletions evap/results/templates/results_index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,15 @@ <h5 class="card-title">
</span>
</h5>
<div class="row">
<div class="col-12 col-lg">
<input type="search" name="search" class="form-control mb-3" placeholder="{% translate 'Search...' %}" />
<div class="col-12 col-lg mb-3 mb-lg-0">
<label>
<input type="search" name="search" class="form-control mb-3" placeholder="{% translate 'Search...' %}" />
</label>

<h6 class="card-subtitle mt-4 mb-1">{% trans 'Participants' %}</h6>
<div id="participant-filter">
{% include 'results_widget_range_slider.html' %}
</div>
</div>
<div class="col-6 col-sm-4 col-md">
<h6 class="card-subtitle mb-1">{% translate 'Programs' %}</h6>
Expand Down Expand Up @@ -129,7 +136,8 @@ <h5 class="card-title mb-lg-0">

{% block additional_javascript %}
<script type="module">
import {ResultGrid} from "{% static 'js/datagrid.js' %}";
import { ResultGrid } from "{% static 'js/datagrid.js' %}";
import { RangeSlider } from "{% static 'js/slider.js' %}";

new ResultGrid({
storageKey: "results-data-grid",
Expand All @@ -150,6 +158,12 @@ <h5 class="card-title mb-lg-0">
selector: ".badge-course-type",
}],
]),
filterSliders: new Map([
["participants", {
slider: new RangeSlider("participant-filter"),
selector: "[data-col=voters]",
}],
]),
sortColumnSelect: document.querySelector("[name=result-sort-column]"),
sortOrderCheckboxes: [...document.querySelectorAll("[name=result-sort-order]")],
resetFilter: document.querySelector("[data-reset=filter]"),
Expand Down
2 changes: 1 addition & 1 deletion evap/results/templates/results_index_evaluation_impl.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
{{ evaluation.course.responsibles_names }}
</div>
{% endif %}
<div data-col="voters" class="text-center" data-order="{{ evaluation|voters_order }}">
<div data-col="voters" class="text-center" data-order="{{ evaluation|voters_order }}" data-filter-value="{{ evaluation.num_participants }}">
{% if evaluation.is_single_result %}
<span class="fas fa-user"></span>&nbsp;{{ evaluation.single_result_rating_result.count_sum }}
{% else %}
Expand Down
9 changes: 9 additions & 0 deletions evap/results/templates/results_widget_range_slider.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<section class="range-slider">
<div class="row mt-3">
<span class="col-4 text-start text-secondary"></span>
<span class="col-4 range-values"></span>
<span class="col-4 text-end text-secondary"></span>
</div>
<input class="form-range" name="low" value="" min="" max="" step="1" type="range">
<input class="form-range" name="high" value="" min="" max="" step="1" type="range">
</section>
1 change: 1 addition & 0 deletions evap/static/scss/_components.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@
@import "components/quick-review";
@import "components/staff-index";
@import "components/notebook";
@import "components/range-slider";
47 changes: 47 additions & 0 deletions evap/static/scss/components/_range-slider.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
section.range-slider {
position: relative;
height: 45px;
text-align: center;
}

section.range-slider input {
pointer-events: none;
position: absolute;
left: 0;
top: 20px;
}

section.range-slider input::-webkit-slider-thumb {
pointer-events: all;
position: relative;
z-index: 1;
outline: 0;
}

section.range-slider input::-moz-range-thumb {
pointer-events: all;
position: relative;
z-index: 10;
-moz-appearance: none;
}

section.range-slider input::-webkit-slider-runnable-track {
background-color: $light-gray;
}

section.range-slider input::-moz-range-track {
position: relative;
z-index: -1;
background-color: $light-gray;
border: 0;
}

section.range-slider input:last-of-type::-moz-range-track {
-moz-appearance: none;
background: none transparent;
border: 0;
}

section.range-slider input[type=range]::-moz-focus-outer {
border: 0;
}
85 changes: 63 additions & 22 deletions evap/static/ts/src/datagrid.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CSRF_HEADERS } from "./csrf-utils.js";
import { RangeSlider, Range } from "./slider.js";

declare const Sortable: typeof import("sortablejs");

Expand All @@ -11,8 +12,9 @@ interface Row {
}

interface State {
equalityFilter: Map<string, string[]>;
rangeFilter: Map<string, Range>;
search: string;
filter: Map<string, string[]>;
order: [string, "asc" | "desc"][];
}

Expand Down Expand Up @@ -139,12 +141,18 @@ abstract class DataGrid {
const isDisplayedBySearch = searchWords.every(searchWord => {
return row.searchWords.some(rowWord => rowWord.includes(searchWord));
});
const isDisplayedByFilters = [...this.state.filter].every(([name, filterValues]) => {
const isDisplayedByFilters = [...this.state.equalityFilter].every(([name, filterValues]) => {
return filterValues.some(filterValue => {
return row.filterValues.get(name)!.some(rowValue => rowValue === filterValue);
return row.filterValues.get(name)?.some(rowValue => rowValue === filterValue);
});
});
row.isDisplayed = isDisplayedBySearch && isDisplayedByFilters;
const isDisplayedByRangeFilters = [...this.state.rangeFilter].every(([name, bound]) => {
return row.filterValues
.get(name)
?.map(rawValue => parseFloat(rawValue))
.some(rowValue => rowValue >= bound.low && rowValue <= bound.high);
});
row.isDisplayed = isDisplayedBySearch && isDisplayedByFilters && isDisplayedByRangeFilters;
}
}

Expand Down Expand Up @@ -192,8 +200,9 @@ abstract class DataGrid {
private restoreStateFromStorage(): State {
const stored = JSON.parse(localStorage.getItem(this.storageKey)!) || {};
return {
equalityFilter: new Map(stored.equalityFilter),
rangeFilter: new Map(stored.rangeFilter),
search: stored.search || "",
filter: new Map(stored.filter),
order: stored.order || this.defaultOrder,
};
}
Expand All @@ -202,8 +211,9 @@ abstract class DataGrid {

private saveStateToStorage() {
const stored = {
equalityFilter: [...this.state.equalityFilter],
rangeFilter: [...this.state.rangeFilter],
search: this.state.search,
filter: [...this.state.filter],
order: this.state.order,
};
localStorage.setItem(this.storageKey, JSON.stringify(stored));
Expand Down Expand Up @@ -283,11 +293,11 @@ export class EvaluationGrid extends TableGrid {
button.addEventListener("click", () => {
if (button.classList.contains("active")) {
button.classList.remove("active");
this.state.filter.delete("evaluationState");
this.state.equalityFilter.delete("evaluationState");
} else {
this.filterButtons.forEach(button => button.classList.remove("active"));
button.classList.add("active");
this.state.filter.set("evaluationState", [button.dataset.filter!]);
this.state.equalityFilter.set("evaluationState", [button.dataset.filter!]);
}
this.filterRows();
this.renderToDOM();
Expand Down Expand Up @@ -316,8 +326,8 @@ export class EvaluationGrid extends TableGrid {

protected reflectFilterStateOnInputs() {
super.reflectFilterStateOnInputs();
if (this.state.filter.has("evaluationState")) {
const activeEvaluationState = this.state.filter.get("evaluationState")![0];
if (this.state.equalityFilter.has("evaluationState")) {
const activeEvaluationState = this.state.equalityFilter.get("evaluationState")![0];
const activeButton = this.filterButtons.find(button => button.dataset.filter === activeEvaluationState)!;
activeButton.classList.add("active");
}
Expand Down Expand Up @@ -369,6 +379,7 @@ export class QuestionnaireGrid extends TableGrid {

interface ResultGridParameters extends DataGridParameters {
filterCheckboxes: Map<string, { selector: string; checkboxes: HTMLInputElement[] }>;
filterSliders: Map<string, { selector: string; slider: RangeSlider }>;
sortColumnSelect: HTMLSelectElement;
sortOrderCheckboxes: HTMLInputElement[];
resetFilter: HTMLButtonElement;
Expand All @@ -378,13 +389,15 @@ interface ResultGridParameters extends DataGridParameters {
// Grid based data grid which has its container separated from its header
export class ResultGrid extends DataGrid {
private readonly filterCheckboxes: Map<string, { selector: string; checkboxes: HTMLInputElement[] }>;
private sortColumnSelect: HTMLSelectElement;
private sortOrderCheckboxes: HTMLInputElement[];
private resetFilter: HTMLButtonElement;
private resetOrder: HTMLButtonElement;
private readonly filterSliders: Map<string, { selector: string; slider: RangeSlider }>;
private readonly sortColumnSelect: HTMLSelectElement;
private readonly sortOrderCheckboxes: HTMLInputElement[];
private readonly resetFilter: HTMLButtonElement;
private readonly resetOrder: HTMLButtonElement;

constructor({
filterCheckboxes,
filterSliders,
sortColumnSelect,
sortOrderCheckboxes,
resetFilter,
Expand All @@ -393,6 +406,7 @@ export class ResultGrid extends DataGrid {
}: ResultGridParameters) {
super(options);
this.filterCheckboxes = filterCheckboxes;
this.filterSliders = filterSliders;
this.sortColumnSelect = sortColumnSelect;
this.sortOrderCheckboxes = sortOrderCheckboxes;
this.resetFilter = resetFilter;
Expand All @@ -406,22 +420,33 @@ export class ResultGrid extends DataGrid {
checkbox.addEventListener("change", () => {
const values = checkboxes.filter(checkbox => checkbox.checked).map(elem => elem.value);
if (values.length > 0) {
this.state.filter.set(name, values);
this.state.equalityFilter.set(name, values);
} else {
this.state.filter.delete(name);
this.state.equalityFilter.delete(name);
}
this.filterRows();
this.renderToDOM();
});
});
}

for (const [name, { slider }] of this.filterSliders.entries()) {
this.state.rangeFilter.set(name, slider.range);

slider.onRangeChange = () => {
this.state.rangeFilter.set(name, slider.range);
this.filterRows();
this.renderToDOM();
};
}

this.sortColumnSelect.addEventListener("change", () => this.sortByInputs());
this.sortOrderCheckboxes.forEach(checkbox => checkbox.addEventListener("change", () => this.sortByInputs()));

this.resetFilter.addEventListener("click", () => {
this.state.search = "";
this.state.filter.clear();
this.state.equalityFilter.clear();
this.state.rangeFilter.clear();
this.filterRows();
this.renderToDOM();
this.reflectFilterStateOnInputs();
Expand Down Expand Up @@ -452,14 +477,22 @@ export class ResultGrid extends DataGrid {
}

protected fetchRowFilterValues(row: HTMLElement): Map<string, string[]> {
let filterValues = new Map();
let filterValues = new Map<string, string[]>();
for (const [name, { selector, checkboxes }] of this.filterCheckboxes.entries()) {
// To store filter values independent of the language, use the corresponding id from the checkbox
const values = [...row.querySelectorAll(selector)]
.map(element => element.textContent!.trim())
.map(filterName => checkboxes.find(checkbox => checkbox.dataset.filter === filterName)?.value);
.map(element => element.textContent?.trim())
.map(filterName => checkboxes.find(checkbox => checkbox.dataset.filter === filterName)?.value)
.filter(v => v !== undefined);
filterValues.set(name, values);
}
for (const [name, { selector, slider }] of this.filterSliders.entries()) {
const values = [...row.querySelectorAll<HTMLElement>(selector)]
.map(element => element.dataset.filterValue)
.filter(v => v !== undefined);
filterValues.set(name, values);
slider.includeValues(values.map(parseFloat));
}
return filterValues;
}

Expand All @@ -475,8 +508,8 @@ export class ResultGrid extends DataGrid {
for (const [name, { checkboxes }] of this.filterCheckboxes.entries()) {
checkboxes.forEach(checkbox => {
let isActive;
if (this.state.filter.has(name)) {
isActive = this.state.filter.get(name)!.some(filterValue => {
if (this.state.equalityFilter.has(name)) {
isActive = this.state.equalityFilter.get(name)!.some(filterValue => {
return filterValue === checkbox.value;
});
} else {
Expand All @@ -485,5 +518,13 @@ export class ResultGrid extends DataGrid {
checkbox.checked = isActive;
});
}
for (const [name, { slider }] of this.filterSliders.entries()) {
const filterRange = this.state.rangeFilter.get(name);
if (filterRange !== undefined) {
slider.range = filterRange;
} else {
slider.reset();
}
}
}
}
Loading

0 comments on commit 2da6090

Please sign in to comment.