Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1586: Szybsze i wygodniejsze wybieranie #1763

Open
wants to merge 9 commits into
base: master-dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type Methods = {
clearFilter: () => void;
clearAll: () => void;
updateDropdownWidth: () => void;
onSearchChange: () => void;
};

export default defineComponent<Props, any, Data, Computed, Methods>({
Expand Down Expand Up @@ -133,6 +134,9 @@ export default defineComponent<Props, any, Data, Computed, Methods>({
}
});
},
onSearchChange() {
this.$emit("search-change", ...arguments);
},
},
computed: {
selectionDescription(): string {
Expand Down Expand Up @@ -163,6 +167,8 @@ export default defineComponent<Props, any, Data, Computed, Methods>({
k: this.filterKey,
f: new ExactFilter(selectedIds, this.property),
});

this.$emit("select", selectedIds);
},
},
});
Expand All @@ -179,6 +185,7 @@ export default defineComponent<Props, any, Data, Computed, Methods>({
:track-by="trackBy"
:label="propAsLabel"
:placeholder="selectionDescription"
@search-change="onSearchChange"
>
<template slot="option" slot-scope="props">
<div class="option-row">
Expand Down
172 changes: 172 additions & 0 deletions zapisy/apps/theses/assets/components/StudentFilter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<script lang="ts">
import Vue from "vue";
import { debounce } from "lodash";

import MultiSelectFilter from "@/enrollment/timetable/assets/components/filters/MultiSelectFilter.vue";
import {
MultiselectFilterData,
MultiselectFilterDataItem,
} from "@/enrollment/timetable/assets/models";

type MultiSelectFilterWithSelected = {
selected: MultiselectFilterDataItem<number>[];
};

export default Vue.extend({
components: {
MultiSelectFilter,
},
data: function () {
return {
students: [] as MultiselectFilterData<number>,
};
},
/**
* Load assigned students, if there are any
*/
mounted: function () {
const djangoField = document.getElementById(
"id_students"
) as HTMLSelectElement | null;
if (djangoField === null) {
return;
}

const options = djangoField.options;
if (options.length === 0) {
// No one assigned
return;
}
// Map each student from <select> data to MultiSelectFilter data
const assigned_students: MultiselectFilterData<number> = Array.from(
options
).map((element) => ({
value: Number(element.value),
label: element.text,
}));

// Store the assigned students to display them after MultiSelectFilter gains focus
this.students = assigned_students;

// Mark the students as selected in MultiSelectFilter
const filter = this.$refs["student-filter"] as Vue &
MultiSelectFilterWithSelected;
if (filter) {
filter.selected = this.students;
}
},
methods: {
onSelect: function (selectedIds: number[]) {
// Update the server <select>
this.updateDjangoField(selectedIds);
},
clearData: function () {
const filter = this.$refs["student-filter"] as Vue &
MultiSelectFilterWithSelected;

if (filter) {
// Leave only assigned students in the dropdown list
this.students = Array.from(filter.selected);
}
},
updateDjangoField: function (selectedIds: number[]) {
const djangoField = document.getElementById(
"id_students"
) as HTMLSelectElement | null;
if (djangoField === null) {
return;
}

const optionArray = Array.from(djangoField.options);
// Find the newly-selected item
const newId = selectedIds.find((id) =>
optionArray.every((option) => option.value !== String(id))
);
// Find the unselected item
const removedIndex = optionArray.findIndex(
(option) => !selectedIds.includes(Number(option.value))
);

// If a new item was selected, add it to the server <select>
if (newId !== undefined) {
const newOption = document.createElement("option");
newOption.value = newId.toString();
newOption.text = this.students.find((s) => s.value === newId)!.label;
newOption.selected = true;
djangoField.options.add(newOption);
}

// If an item was unselected, remove it from the server <select>
if (removedIndex !== -1) {
djangoField.options.remove(removedIndex);
}
},
fetchStudents: async function (
substring: string
): Promise<{ students: MultiselectFilterData<number> }> {
const ajaxUrlInput = document.querySelector(
"input#ajax-url"
) as HTMLInputElement | null;

if (ajaxUrlInput === null) {
throw new Error("#ajax-url not found.");
}

// Get the endpoint URI
const ajaxUrl = ajaxUrlInput.value;
const urlSafeSubstring = encodeURIComponent(substring);
// Fetch students matching the query
const response = await fetch(`${ajaxUrl}/${urlSafeSubstring}`);
return response.json();
},
onSearchChange: debounce(function (
this: { updateStudents: (search: string) => void },
search: string
) {
// Update the dropdown list after changing the search input
return this.updateStudents(search);
},
300),
updateStudents: async function (search: string) {
// Remove unselected students from the dropdown
this.clearData();

if (search.length === 0) {
return;
}

// Fetch students matching the query
const { students: fetchedStudents } = await this.fetchStudents(search);

// Add only the students which are not selected to avoid duplication
const notSelectedStudents = fetchedStudents.filter((fetchedStudent) =>
this.students.every((s) => s.value !== fetchedStudent.value)
);

this.students.push(...notSelectedStudents);
},
},
});
</script>

<template>
<div class="bg-light filters-card">
<MultiSelectFilter
filterKey="student-filter"
property="student"
:options="students"
title="Przypisani studenci"
placeholder="Szukaj po imieniu, nazwisku, numerze indeksu..."
ref="student-filter"
@select="onSelect"
@search-change="onSearchChange"
/>
</div>
</template>

<style lang="scss" scoped>
.filters-card {
transform: scale(1);
z-index: 2;
}
</style>
24 changes: 24 additions & 0 deletions zapisy/apps/theses/assets/student-filter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Vue from "vue";
import Vuex from "vuex";

import StudentFilter from "./components/StudentFilter.vue";
import filters from "@/enrollment/timetable/assets/store/filters";

Vue.use(Vuex);

const store = new Vuex.Store({
modules: {
filters,
},
});

// Get the server field
const djangoField = document.getElementById("id_students");

// Replace it with the placeholder for the custom MultiSelectFilter
const multiselectPlaceholder = document.getElementById("student-filter");
djangoField.before(multiselectPlaceholder);
djangoField.style.display = "none";

// Create the custom MultiSelectFilter
new Vue({ el: "#student-filter", render: (h) => h(StudentFilter), store });
23 changes: 22 additions & 1 deletion zapisy/apps/theses/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from crispy_forms.layout import Column, Layout, Row, Submit
from django import forms
from django.utils import timezone
from django.db.models import Q

from apps.common import widgets as common_widgets
from apps.theses.enums import ThesisKind, ThesisStatus, ThesisVote
Expand Down Expand Up @@ -43,7 +44,7 @@ class Meta:
required=False)
kind = forms.TypedChoiceField(choices=ThesisKind.choices, label="Typ", coerce=int)
students = forms.ModelMultipleChoiceField(
queryset=Student.objects.all(),
queryset=Student.objects.none(),
required=False,
label="Przypisani studenci",
widget=forms.SelectMultiple(attrs={'size': '10'}))
Expand Down Expand Up @@ -73,6 +74,10 @@ def __init__(self, user, *args, **kwargs):

self.fields['status'].required = False

if 'students' in self.initial:
assigned_ids = [s.id for s in self.initial['students']]
self.fields['students'].queryset = Student.objects.filter(Q(id__in=assigned_ids))

self.helper = FormHelper()
self.helper.form_method = 'POST'
self.helper.layout = Layout(
Expand All @@ -93,6 +98,22 @@ def __init__(self, user, *args, **kwargs):

def clean(self):
super().clean()
# Handle the mess caused by django not recognizing the selected students
# (line 47: `queryset=Student.objects.none(),`)
if 'students' in self.data:
if 'students' in self.errors:
# No error, trust me bro
self.errors.pop('students')
# Handle the mess caused by a different data structure
# appearing in tests for some reason
# QueryDict in normal use; Python dict in tests; wtf
ids_or_students = self.data.getlist('students') if 'getlist' in dir(self.data) else self.data['students']
# Help django find out the students actually exist
if len(ids_or_students) != 0 and isinstance(ids_or_students[0], str):
self.cleaned_data['students'] = Student.objects.filter(Q(id__in=ids_or_students))
else:
self.cleaned_data['students'] = ids_or_students

students = self.cleaned_data['students']
max_number_of_students = int(self.cleaned_data['max_number_of_students'])
if ('students' in self.changed_data or 'max_number_of_students' in self.changed_data) \
Expand Down
3 changes: 3 additions & 0 deletions zapisy/apps/theses/templates/theses/thesis_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ <h1 class="d-inline-block">
<form method="POST" id="confirm-submit" class="post-form">
{% csrf_token %}
{% crispy thesis_form %}
<div id="student-filter"></div>
<input type="hidden" id="ajax-url" value='{% url "theses:students" %}' />
{% render_bundle 'theses-student-filter' %}
</form>
{% if confirm_changes %}
{{ old_instance|json_script:"old_instance" }}
Expand Down
2 changes: 2 additions & 0 deletions zapisy/apps/theses/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@
path('<int:id>/form/<int:studentid>', views.gen_form, name="gen_form"),
path('<int:id>/rejecter', views.rejecter_decision, name="rejecter_thesis"),
path('<int:id>/delete', views.delete_thesis, name="delete_thesis"),
path('students', views.students, name="students"),
path('students/<str:substring>', views.students, name="students"),
]
23 changes: 21 additions & 2 deletions zapisy/apps/theses/views.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_POST, require_GET
from django.forms.models import model_to_dict
from django.db.models import Q

from apps.theses.enums import ThesisStatus, ThesisVote
from apps.theses.forms import EditThesisForm, RejecterForm, RemarkForm, ThesisForm, VoteForm
Expand Down Expand Up @@ -281,3 +282,21 @@ def delete_thesis(request, id):
thesis.delete()
messages.success(request, 'Pomyślnie usunięto pracę dyplomową')
return redirect('theses:main')


@require_GET
@employee_required
def students(request, substring):
conditions = (
Q(user__first_name__icontains=substring) |
Q(user__last_name__icontains=substring) |
Q(matricula__icontains=substring)
)
matching_students = Student.objects.filter(conditions)
# Return matching students in a MultiSelectFilter-friendly format
return JsonResponse({'students': [
{
'value': s.id,
'label': str(s)
} for s in matching_students
]})
3 changes: 3 additions & 0 deletions zapisy/webpack_resources/asset-defs.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ const AssetDefs = {

"theses-theses-widget": [path.resolve("apps/theses/assets/theses-widget.js")],
"theses-theses-change": [path.resolve("apps/theses/assets/theses-change.js")],
"theses-student-filter": [
path.resolve("apps/theses/assets/student-filter.js"),
],

// User app

Expand Down
Loading