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

Add filters to the admin panel #54

Open
wants to merge 8 commits into
base: master
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,5 @@ CHECKOUT_TOKEN=koala
# database
docker-compose up -d
# server
nix-shell --run "./manage.py runserver"
dotenv nix-shell --run "./manage.py runserver"
```
147 changes: 137 additions & 10 deletions admin_board_view/static/AdminBoardView/products.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,146 @@ function delete_product(id) {
}

// Filter products
const filter_input = document.getElementById("filter-products");
if (filter_input) {
filter_input.addEventListener("keyup", e => {
const filter = filter_input.value.toLowerCase();
const products = document.getElementsByClassName("product-row");
Array.from(products).forEach(product => {
const name = product.querySelector(".product-name").innerHTML.toLowerCase();
if (name.includes(filter)) {
product.style.display = "";
const filterInput = document.getElementById("filter-products");
const suggestionsContainer = document.getElementById("autocomplete-suggestions");

//Define suggestions
let categories = []
fetch('/product/category', { data: { csrfmiddlewaretoken: csrf_token } })
.then(response => response.json())
.then(data => categories = data.map(cat => cat.toLowerCase()))

const statuses = ["active", "inactive"];

let currFocus = -1;
if (filterInput) {
filterInput.addEventListener("input", e => {
// console.log(e.key);
const filterString = filterInput.value.toLowerCase();
suggestions = suggestionsContainer.getElementsByTagName("div")
cursorPos = filterInput.selectionStart

//Change suggestions based on input keys
if(e.key != "Enter"){
//Get last word before cursor position
const lastWord = filterString.slice(0,cursorPos).split(" ").pop();
suggestionsContainer.innerHTML = '';

if (lastWord.startsWith("category:")) {
showSuggestions(categories, lastWord.replace("category:", "").trim());
} else if (lastWord.startsWith("status:")) {
showSuggestions(statuses, lastWord.replace("status:", "").trim());
}
}
});

//Adjust autocomplete focus and submit
filterInput.addEventListener("keydown", e => {
const filterString = filterInput.value.toLowerCase();
if(e.key == "ArrowDown"){
e.preventDefault();
currFocus++;
highlightFocussed(suggestions)
} else if(e.key == "ArrowUp"){
e.preventDefault();
currFocus--;
highlightFocussed(suggestions)
} else if (e.key === "Enter") {
if(currFocus > -1 && suggestions.length > 0){
suggestions[currFocus].click();
}
showFilters(filterString);
}
return false;
})

//Finish word when suggestion is pressed
suggestionsContainer.addEventListener("click", e => {
if (e.target && e.target.matches("div.suggestion")) {
//Split on cursor position
firstPart = filterInput.value.slice(0,filterInput.selectionStart)
lastPart = filterInput.value.slice(filterInput.selectionStart)
//Trim to last colon and combine
firstPart = firstPart.slice(0,firstPart.lastIndexOf(':')+1)
filterInput.value = firstPart + e.target.innerText + lastPart
suggestionsContainer.innerHTML = '';
currFocus = -1;
}
});
//Add highlight to the active suggestion
function highlightFocussed(suggestions) {
if (!suggestions || suggestions.length == 0) return false;

//Clear all highlights and highlight the focussed
removeAllHighlights(suggestions);
if (currFocus >= suggestions.length) currFocus = 0;
if (currFocus < 0) currFocus = (suggestions.length - 1);
suggestions[currFocus].classList.add("suggestion-active");
}

function removeAllHighlights(suggestions) {
for (var i = 0; i < suggestions.length; i++) {
suggestions[i].classList.remove("suggestion-active");
}
}
}

//Hide all elements not matching filter
function showFilters(filterString){
const [searchTerm, filters] = getFilters(filterString);
const products = document.getElementsByClassName("product-row");
Array.from(products).forEach(product => {

//Get product properties
const name = product.querySelector(".product-name").innerHTML.toLowerCase();
const category = product.querySelector(".product-category").innerHTML.toLowerCase();
const active = product.getAttribute("data-status").toLowerCase();

//Convert bool to activity string
const activeFilter = active === "true" ? "active" : "inactive";

// Only filter by criteria if they are specified (non-empty)
const nameMatches = name.includes(searchTerm);
const categoryMatches = !filters.category || category.includes(filters.category);
const statusMatches = !filters.status || activeFilter == filters.status;

product.style.display = nameMatches && categoryMatches && statusMatches
? ""
: "none";
})

//Turn filter string into filter object
function getFilters(filterString) {
const filterNames = ["category", "status"]
const strings = filterString.split(" ");
const filters = {};
const searchTerm = [];

//Check if filter exists then assign value in object
strings.forEach(string => {
const [type, value] = string.toLowerCase().split(":");
if (value && filterNames.includes(type)) {
filters[type] = value;
} else {
product.style.display = "none";
searchTerm.push(string);
}
});
return [searchTerm.join(" ").trim(), filters];
}
}

//Add divs for each suggestion to the suggestionContainer
function showSuggestions(suggestions, input) {
//Get list of suggestions that match input so far
const filteredSuggestions = suggestions.filter(suggestion =>
suggestion.startsWith(input)
);

filteredSuggestions.forEach(suggestion => {
const suggestionElement = document.createElement("div");
suggestionElement.className = "suggestion";
suggestionElement.innerText = suggestion;
suggestionsContainer.appendChild(suggestionElement);
});
}

Expand Down
24 changes: 24 additions & 0 deletions admin_board_view/static/AdminBoardView/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,29 @@ a {
text-decoration: none;
}

.autocomplete-suggestions {
border: 1px solid #ccc;
max-height: 150px;
overflow-y: auto;
position: absolute;
background-color: white;
z-index: 1000;
width: calc(100% - 2px);
}

.suggestion {
padding: 1%;
cursor: pointer;
}

.suggestion:hover {
background-color: #e0e0e0;
}
.suggestion-active {
/*when navigating through the items using the arrow keys:*/
background-color: DodgerBlue !important;
color: #ffffff;
}

.pagination {
width: fit-content;
Expand All @@ -65,3 +88,4 @@ a {
/* Margin to make sure it's properly aligned: (50-16)/2 */
margin: 17px;
}

7 changes: 4 additions & 3 deletions admin_board_view/templates/products.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ <h3 class="flex-grow-1">Products</h3>
</section>
</div>
<div class="card-body overflow-x-auto">
<input id="filter-products" class="form-control" type="text" placeholder="Type product..." />
<input id="filter-products" class="form-control" type="text" placeholder="Type product name or category:category_name or status:active/inactive" />
<div id="autocomplete-suggestions" class="autocomplete-suggestions"></div>
<hr/>
<table class="table table-striped table-hover text-center align-middle">
<thead>
Expand All @@ -23,11 +24,11 @@ <h3 class="flex-grow-1">Products</h3>
</thead>
<tbody>
{% for product in products %}
<tr id="{{ product.id }}" class="product-row">
<tr id="{{ product.id }}" data-status="{{ product.enabled }}" class="product-row">
<td class="product-image {% if not product.enabled %}disabled-product{% endif %}">{{ product.image_view }}</td>
<td class="product-name">{{ product.name }}</td>
<td>{{ product.euro }}</td>
<td>{{ product.category }}</td>
<td class="product-category">{{ product.category }}</td>
<td>
<div class="mb-1">
<a href="/products?edit={{ product.id }}"><button class="btn btn-primary edit-product">Edit</button></a>
Expand Down
1 change: 1 addition & 0 deletions admin_board_view/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

path('product/toggle', views.toggle, name='toggle'),
path('product/delete', views.delete, name='delete'),
path('product/category', views.categories_json, name='category_json'),

path('category/edit', views.category, name='category'),
path('vat/edit', views.vat, name='vat'),
Expand Down
4 changes: 4 additions & 0 deletions admin_board_view/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ def products(request):
categories = Category.objects.all()
return render(request, "products.html", { "products": products, "categories": categories, "product_form": pf, "current_product": product, "product_sales": product_sales })

@dashboard_admin
def categories_json(request):
categories = list(Category.objects.values_list('name', flat=True))
return JsonResponse(categories, safe=False)

@dashboard_admin
def delete(request):
Expand Down