Skip to content
Snippets Groups Projects
Commit 93a4ccc5 authored by Odin Johan Vatne's avatar Odin Johan Vatne
Browse files

Reworked filter section

* Add custom ternary checkboxes for include/exclude
* Automatically select filters for students
* Make filter sections collapsible
* Add clear filters button
parent 9f84f1e2
No related branches found
No related tags found
No related merge requests found
Showing
with 243 additions and 39 deletions
...@@ -19,7 +19,7 @@ body { ...@@ -19,7 +19,7 @@ body {
text-decoration: underline; text-decoration: underline;
} }
h5.nobreak { .nobreak {
display: inline-block; display: inline-block;
} }
...@@ -65,3 +65,8 @@ button, input[type=submit] { ...@@ -65,3 +65,8 @@ button, input[type=submit] {
button { button {
margin-top: 4px; margin-top: 4px;
} }
.click-pointer {
cursor: pointer;
user-select: none;
}
\ No newline at end of file
...@@ -24,3 +24,55 @@ ...@@ -24,3 +24,55 @@
height: 1.4em; height: 1.4em;
vertical-align: text-top; vertical-align: text-top;
} }
.collapsible.collapsed {
height: 0;
overflow: hidden;
}
.custom-checkbox {
appearance: none;
display: inline-block;
padding: 0;
box-sizing: content-box;
margin: 0 2px;
width: 14px;
height: 14px;
background-color: #fff;
border: 1px solid #777;
border-radius: 0.15em;
transform: translateY(0.08em);
cursor: pointer;
background-size: contain;
background-repeat: no-repeat;
}
.custom-checkbox:hover {
background-color: #eee;
}
.custom-checkbox.check {
background-image: url(../img/check-grey.svg);
}
.custom-checkbox.check.checked, .custom-checkbox.check:checked {
background-color: rgb(156, 204, 101);
background-image: url(../img/check-white.svg);
}
.custom-checkbox.check.checked:hover, .custom-checkbox.check:checked:hover {
background-color: rgb(107, 156, 56);
}
.custom-checkbox.cross {
background-image: url(../img/cross-grey.svg);
}
.custom-checkbox.cross.checked {
background-color: rgb(198, 40, 40);
background-image: url(../img/cross-white.svg);
}
.custom-checkbox.cross.checked:hover {
background-color: rgb(171, 0, 13);
}
...@@ -3,6 +3,11 @@ ...@@ -3,6 +3,11 @@
border: 1px rgba(49, 235, 179, 1) solid; border: 1px rgba(49, 235, 179, 1) solid;
} }
.tag.no-bubble {
background-color: transparent;
border: transparent;
}
.tag.prerequisite-class { .tag.prerequisite-class {
background-color: rgba(220, 38, 127, 0.6); background-color: rgba(220, 38, 127, 0.6);
border-color: rgba(220, 38, 127, 1); border-color: rgba(220, 38, 127, 1);
......
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path fill="#ccc" d="M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z" /></svg>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path fill="#ffffff" d="M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z" /></svg>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path fill="#ccc" d="M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z" /></svg>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path fill="#ffffff" d="M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z" /></svg>
\ No newline at end of file
function collapse(div) {
const collapsibleDiv = div.parentNode.querySelector(".collapsible");
collapsibleDiv.classList.toggle("collapsed");
}
function clearFilters() {
const columns = document.getElementsByClassName('filter-column')
const tagFilters = columns[0]
const profFilters = columns[1]
for (box of tagFilters.querySelectorAll('.custom-checkbox.checked')) {
filterCheckbox(box);
}
for (box of profFilters.querySelectorAll('input[type=checkbox]:checked')) {
box.checked = false;
}
}
\ No newline at end of file
function filterCheckbox(box) {
const parent = box.parentElement
const field = parent.querySelector('input[type=hidden]')
const boxClass = box.classList.contains('cross') ? 'cross' : 'check';
const siblingClass = (boxClass == 'cross') ? 'check' : 'cross';
const sibling = parent.querySelector('.' + siblingClass);
box.classList.toggle("checked");
let val = ''
if (isChecked(box)) {
val = (boxClass == 'check') ? 'I' : 'E';
uncheck(sibling);
}
field.value = val;
}
function filterLabel(label) {
const parent = label.parentElement
const checkBox = parent.querySelector('.check');
const crossBox = parent.querySelector('.cross');
const field = parent.querySelector('input[type=hidden]')
let val = '';
if (!isChecked(checkBox) && !isChecked(crossBox)) {
check(checkBox);
val = 'I';
} else if (isChecked(checkBox)) {
uncheck(checkBox);
check(crossBox);
val = 'E';
} else if (isChecked(crossBox)) {
uncheck(crossBox);
}
field.value = val;
}
function isChecked(div) {
return div.classList.contains('checked');
}
function check(div) {
div.classList.add('checked');
}
function uncheck(div) {
div.classList.remove('checked');
}
\ No newline at end of file
{% load html_components %}
{% load custom_filters %}
{% for category, tags in tagsByCategory.items %} {% for category, tags in tagsByCategory.items %}
<h5 class="nobreak">{{category}}</h5> <div class="filter-category">
<div class="tag tag-bubble {{ category|lower|slugify }}"></div><br/> <div class="filter-header" onclick="collapse(this)">
<h5 class="nobreak click-pointer">{{category}}</h5>
<div class="tag tag-bubble {{ category|lower|slugify }}"></div>
</div>
<div class="collapsible">
{% for tag in tags %} {% for tag in tags %}
{% if tag.id in selectedTags %} <div class="checkbox-row">
<input type="checkbox" name="tag{{tag.id}}" id="tag{{tag.id}}" checked/> <input type="hidden" name="tag{{tag.id}}" id="tag{{tag.id}}" value="{{selectedTags|get:tag.id}}">
{% else %} {{ selectedTags|ternary_checkbox:tag.id }}
<input type="checkbox" name="tag{{tag.id}}" id="tag{{tag.id}}"/> <label for="tag{{tag.id}}" class="click-pointer {{ tag.category|lower|slugify }}" onclick="filterLabel(this)">{{tag.name}}</label>
{% endif %}
<label for="tag{{tag.id}}" class="{{ tag.category|lower|slugify }}">{{tag.name}}</label>
<br/> <br/>
</div>
{% endfor %} {% endfor %}
</div>
</div>
{% endfor %} {% endfor %}
\ No newline at end of file
{% extends 'pasapp/templates/base_template.html' %} {% extends 'pasapp/templates/base_template.html' %}
{% block title %} Projects {% endblock %} {% block title %} Projects {% endblock %}
{% block imports %}
{% load static %}
<script src={% static 'pasapp/js/filters.js' %}></script>
<script src={% static 'pasapp/js/ternary_checkbox.js' %}></script>
{% endblock %}
{% block content %} {% block content %}
{% load custom_filters %} {% load custom_filters %}
{% load html_components %} {% load html_components %}
...@@ -32,31 +37,32 @@ ...@@ -32,31 +37,32 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p>No projects are available.</p> <p>There are no projects that match your current filter settings. Consider relaxing your filter criteria.</p>
{% endif %} {% endif %}
</div> </div>
<div id="sidebar"> <div id="sidebar">
<h3>Filter projects</h3>
<form action="/projects/filter/" method="post"> <form action="/projects/filter/" method="post">
{% csrf_token %} {% csrf_token %}
<h3 class="nobreak">Filter projects</h3> <button onclick="clearFilters()">Clear</button> <input type="submit" value="Filter"/>
<div class="split-content-container"> <div class="split-content-container">
<div class="filter-column"> <div class="filter-column">
{% tagfilters allTags selectedTags %} {% tagfilters allTags selectedTags %}
</div> </div>
<div class="filter-column"> <div class="filter-column">
<h5>Professors</h5> <h5 class="nobreak">Professors</h5>
<div class="tag tag-bubble no-bubble"></div><br>
{% for professor in professors %} {% for professor in professors %}
{% if professor.id in selectedProfessors %} {% if professor.id in selectedProfessors %}
<input type="checkbox" name="prof{{professor.id}}" checked/> <input type="checkbox" name="prof{{professor.id}}" class="custom-checkbox check" checked/>
{% else %} {% else %}
<input type="checkbox" name="prof{{professor.id}}"/> <input type="checkbox" name="prof{{professor.id}}" class="custom-checkbox check"/>
{% endif %} {% endif %}
<label for="prof{{professor.id}}">{{professor.first_name}} {{professor.last_name}}</label> <label for="prof{{professor.id}}">{{professor.first_name}} {{professor.last_name}}</label>
<br/> <br/>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<input type="submit" value="Filter"/> <button onclick="clearFilters()">Clear</button> <input type="submit" value="Filter"/>
</form> </form>
</div> </div>
</div> </div>
......
...@@ -42,3 +42,7 @@ def times(number): ...@@ -42,3 +42,7 @@ def times(number):
@register.filter(name='for_application') @register.filter(name='for_application')
def for_application(comments, application): def for_application(comments, application):
return comments.filter(application=application) if comments else [] # TODO: check if we can guarantee comments is QuerySet return comments.filter(application=application) if comments else [] # TODO: check if we can guarantee comments is QuerySet
@register.filter(name='get')
def get(map, key):
return map[key] if key in map else ''
\ No newline at end of file
from django import template from django import template
from pasapp.forms import CommentForm from pasapp.forms import CommentForm
from pasapp.utils import tagsByCategory from pasapp.utils import tagsByCategory
from pasapp.templatetags.custom_filters import get as get_or_blank
from django.utils.safestring import SafeString
from django.utils.safestring import mark_safe
register = template.Library() register = template.Library()
...@@ -27,6 +30,15 @@ def tagfilters(all_tags, selected_tags): ...@@ -27,6 +30,15 @@ def tagfilters(all_tags, selected_tags):
tags_by_category = tagsByCategory(all_tags) tags_by_category = tagsByCategory(all_tags)
return { 'tagsByCategory': tags_by_category, 'selectedTags': selected_tags } return { 'tagsByCategory': tags_by_category, 'selectedTags': selected_tags }
def checkbox_base(mark, checked):
checked_class = 'checked' if checked else ''
return f'<div class="custom-checkbox {mark} {checked_class}" onclick="filterCheckbox(this)"></div>'
@register.filter()
def ternary_checkbox(tags, tag):
state = get_or_blank(tags, tag)
return mark_safe(checkbox_base('check', state == 'I') + checkbox_base('cross', state == 'E'))
@register.inclusion_tag('pasapp/components/tags_dropdown.html') @register.inclusion_tag('pasapp/components/tags_dropdown.html')
def tags_dropdown(tags): def tags_dropdown(tags):
tags_by_category = tagsByCategory(tags) tags_by_category = tagsByCategory(tags)
......
from sys import stdout
from django.contrib.auth import authenticate, login, logout, get_user_model from django.contrib.auth import authenticate, login, logout, get_user_model
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.forms import AuthenticationForm
...@@ -8,7 +7,7 @@ from django.http import HttpResponse ...@@ -8,7 +7,7 @@ from django.http import HttpResponse
from django.db.models import F from django.db.models import F
from pasapp.forms import ApplicationForm, NewProjectForm, NewUserForm,CommentForm from pasapp.forms import ApplicationForm, NewProjectForm, NewUserForm,CommentForm
from pasapp.models import Application, Comment, Project, ProjectTag, UserTag, Tag from pasapp.models import Application, Comment, Project, ProjectTag, UserTag, Tag, TagCategory
from pasapp.utils import contextWithHeader from pasapp.utils import contextWithHeader
from pasapp.templatetags.custom_filters import is_professor, is_student from pasapp.templatetags.custom_filters import is_professor, is_student
from pasapp.queries import * from pasapp.queries import *
...@@ -19,39 +18,83 @@ def index(request): ...@@ -19,39 +18,83 @@ def index(request):
Lists all active projects. Also handles filtering those projects via POST requests. Lists all active projects. Also handles filtering those projects via POST requests.
URL: / URL: /
''' '''
tag_ids, professor_ids = [], [] included_professor_ids = []
category_filters = {} # category: [IDs to include, IDs to exclude]
selected_tags = {} # id: 'I' or 'E'
if request.method == "POST": if request.method == "POST":
post_request = request.POST post_request = request.POST
# Sort the IDs sent by the filter post request into professor IDs and tag IDs # Sort the IDs sent by the filter post request into professor IDs and tag IDs
for key in post_request.keys(): for key in post_request.keys():
if key.startswith("prof"): if key.startswith("prof"):
professor_ids.append(int(key.replace("prof", "", 1))) included_professor_ids.append(int(key.replace("prof", "", 1)))
elif key.startswith("tag"): elif key.startswith("tag"):
tag_ids.append(int(key.replace("tag", "", 1))) if post_request[key] == '': continue
id = int(key.replace("tag", "", 1))
tag = Tag.objects.get(pk=id)
cat = tag.category
if cat not in category_filters:
category_filters[cat] = ([], [])
cat_incl, cat_excl = category_filters[cat]
if post_request[key] == 'I':
cat_incl.append(id)
selected_tags[id] = 'I'
elif post_request[key] == 'E':
cat_excl.append(id)
selected_tags[id] = 'E'
else:
user = request.user
if (is_student(user)):
exclusion_categories = [TagCategory.objects.get(category="Prerequisite Class")] # TODO: Generalize using a field in TagCategory model
tags = [pair.tag for pair in UserTag.objects.filter(user=user)]
student_prerequisites = []
for tag in tags:
cat = tag.category
if cat not in category_filters:
category_filters[cat] = ([], [])
cat_incl, cat_excl = category_filters[cat]
if cat in exclusion_categories:
student_prerequisites.append(tag.id)
else:
cat_incl.append(tag.id)
selected_tags[tag.id] = 'I'
missing_prerequisites = Tag.objects.filter(category__in=exclusion_categories).exclude(id__in=student_prerequisites)
for cat in exclusion_categories:
category_filters[cat] = ([], [])
for tag in missing_prerequisites:
cat_incl, cat_excl = category_filters[tag.category]
cat_excl.append(tag.id)
selected_tags[tag.id] = 'E'
# Within each filter section (e.g. tag filters), we OR each selected option.
# If no filters are selected for a section, we just return all projects.
projects = get_open_projects() projects = get_open_projects()
project_tags = get_open_tags()
if professor_ids and len(professor_ids) > 0: if included_professor_ids:
projects = projects.filter(professor_id__in=professor_ids) projects = projects.filter(professor_id__in=included_professor_ids)
project_tags = project_tags.filter(project__in=projects)
if tag_ids and len(tag_ids) > 0:
project_tags = get_open_tags().filter(tag_id__in=tag_ids) if category_filters:
projects = projects.filter(id__in=[pair.project.id for pair in project_tags]) for cat in category_filters:
cat_incl, cat_excl = category_filters[cat]
else: if cat_incl:
projects = get_open_projects() cat_project_tags = project_tags.filter(tag_id__in=cat_incl)
cat_project_ids = [pair.project.id for pair in cat_project_tags]
projects = projects.filter(id__in=cat_project_ids)
project_tags = project_tags.filter(project_id__in=cat_project_ids)
if cat_excl:
cat_project_tags = project_tags.filter(tag_id__in=cat_excl)
cat_project_ids = [pair.project.id for pair in cat_project_tags]
projects = projects.exclude(id__in=cat_project_ids)
project_tags = project_tags.exclude(project_id__in=cat_project_ids)
tag_filters = Tag.objects.all() tag_filters = Tag.objects.all()
project_tags = get_open_tags()
# Only show professors who have published a project # Only show professors who have published a project
professor_filters = { professor_filters = {
project.professor for project in get_open_projects()} project.professor for project in get_open_projects()}
context = {'projects': projects, 'projectTags': project_tags, 'allTags': tag_filters, 'professors': professor_filters, context = {'projects': projects, 'projectTags': project_tags, 'allTags': tag_filters, 'professors': professor_filters,
'selectedTags': tag_ids, 'selectedProfessors': professor_ids} 'selectedTags': selected_tags, 'selectedProfessors': included_professor_ids}
return render(request, 'pasapp/pages/projects.html', contextWithHeader(context, request)) return render(request, 'pasapp/pages/projects.html', contextWithHeader(context, request))
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment