mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-08-26 05:13:58 +02:00
feat(transactions): add htmx filter presets
This commit is contained in:
@@ -14,7 +14,7 @@ from apps.monthly_overview.utils.daily_spending_allowance import (
|
|||||||
calculate_daily_allowance_currency,
|
calculate_daily_allowance_currency,
|
||||||
)
|
)
|
||||||
from apps.transactions.filters import TransactionsFilter
|
from apps.transactions.filters import TransactionsFilter
|
||||||
from apps.transactions.models import Transaction
|
from apps.transactions.models import FilterPreset, Transaction
|
||||||
from apps.transactions.utils.calculations import (
|
from apps.transactions.utils.calculations import (
|
||||||
calculate_currency_totals,
|
calculate_currency_totals,
|
||||||
calculate_percentage_distribution,
|
calculate_percentage_distribution,
|
||||||
@@ -58,6 +58,8 @@ def monthly_overview(request, month: int, year: int):
|
|||||||
"previous_month": previous_month,
|
"previous_month": previous_month,
|
||||||
"previous_year": previous_year,
|
"previous_year": previous_year,
|
||||||
"filter": f,
|
"filter": f,
|
||||||
|
"filter_is_active": f.has_active_filters,
|
||||||
|
"filter_presets": FilterPreset.objects.filter(owner=request.user),
|
||||||
"order": order,
|
"order": order,
|
||||||
"summary_tab": summary_tab,
|
"summary_tab": summary_tab,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ class MonthYearFilter(Filter):
|
|||||||
|
|
||||||
|
|
||||||
class TransactionsFilter(django_filters.FilterSet):
|
class TransactionsFilter(django_filters.FilterSet):
|
||||||
|
default_filter_values = {
|
||||||
|
"type": {"IN", "EX"},
|
||||||
|
"is_paid": {"1", "0"},
|
||||||
|
"mute_status": {"active", "muted"},
|
||||||
|
}
|
||||||
|
|
||||||
description = django_filters.CharFilter(
|
description = django_filters.CharFilter(
|
||||||
label=_("Content"),
|
label=_("Content"),
|
||||||
method=content_filter,
|
method=content_filter,
|
||||||
@@ -217,6 +223,23 @@ class TransactionsFilter(django_filters.FilterSet):
|
|||||||
]
|
]
|
||||||
self.form.fields["entities"].choices = custom_entity_choices + entity_choices
|
self.form.fields["entities"].choices = custom_entity_choices + entity_choices
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_active_filters(self):
|
||||||
|
for name in self.base_filters:
|
||||||
|
if hasattr(self.form.data, "getlist"):
|
||||||
|
values = self.form.data.getlist(name)
|
||||||
|
else:
|
||||||
|
value = self.form.data.get(name)
|
||||||
|
values = value if isinstance(value, (list, tuple)) else [value]
|
||||||
|
|
||||||
|
values = [str(value) for value in values if value not in (None, "")]
|
||||||
|
if not values:
|
||||||
|
continue
|
||||||
|
if set(values) == self.default_filter_values.get(name):
|
||||||
|
continue
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def filter_category(queryset, name, value):
|
def filter_category(queryset, name, value):
|
||||||
if not value:
|
if not value:
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.transactions.models import FilterPreset
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
STORAGES={
|
||||||
|
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
WHITENOISE_AUTOREFRESH=True,
|
||||||
|
)
|
||||||
|
class FilterPresetViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
user_model = get_user_model()
|
||||||
|
self.user = user_model.objects.create_user(
|
||||||
|
email="preset-owner@example.com", password="testpass123"
|
||||||
|
)
|
||||||
|
self.other_user = user_model.objects.create_user(
|
||||||
|
email="other-user@example.com", password="testpass123"
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.preset = FilterPreset.objects.create(
|
||||||
|
owner=self.user,
|
||||||
|
name="Unpaid",
|
||||||
|
parameters={"is_paid": ["0"], "type": ["IN", "EX"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_stores_only_transaction_filter_fields(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("filter_preset_create"),
|
||||||
|
{
|
||||||
|
"name": "Account X Unpaid",
|
||||||
|
"account": ["Account X"],
|
||||||
|
"is_paid": ["0"],
|
||||||
|
"order": "newer",
|
||||||
|
},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
|
||||||
|
preset = FilterPreset.objects.get(
|
||||||
|
owner=self.user, name="Account X Unpaid"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
preset.parameters,
|
||||||
|
{"account": ["Account X"], "is_paid": ["0"]},
|
||||||
|
)
|
||||||
|
self.assertContains(response, "Account X Unpaid")
|
||||||
|
|
||||||
|
def test_create_rejects_a_blank_name(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("filter_preset_create"),
|
||||||
|
{"name": " ", "is_paid": ["0"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(FilterPreset.objects.filter(owner=self.user).count(), 1)
|
||||||
|
|
||||||
|
def test_apply_returns_the_saved_filter_form_without_changing_the_url(self):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("filter_preset_apply", args=[self.preset.pk]),
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'id="filter"')
|
||||||
|
self.assertEqual(response.context["filter"].data.getlist("is_paid"), ["0"])
|
||||||
|
self.assertEqual(
|
||||||
|
response.context["filter"].data.getlist("type"), ["IN", "EX"]
|
||||||
|
)
|
||||||
|
self.assertNotIn("HX-Push-Url", response.headers)
|
||||||
|
self.assertNotIn("HX-Replace-Url", response.headers)
|
||||||
|
self.assertIs(response.context.get("filter_is_active"), True)
|
||||||
|
self.assertContains(response, 'hx-swap-oob="outerHTML"')
|
||||||
|
self.assertEqual(
|
||||||
|
response.headers["HX-Trigger-After-Settle"],
|
||||||
|
"updated",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clear_returns_the_default_filter_form_without_changing_the_url(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/transactions/filter/clear/",
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'id="filter"')
|
||||||
|
self.assertEqual(
|
||||||
|
response.context["filter"].data.getlist("type"), ["IN", "EX"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
response.context["filter"].data.getlist("is_paid"), ["1", "0"]
|
||||||
|
)
|
||||||
|
self.assertNotIn("HX-Push-Url", response.headers)
|
||||||
|
self.assertNotIn("HX-Replace-Url", response.headers)
|
||||||
|
self.assertIs(response.context.get("filter_is_active"), False)
|
||||||
|
self.assertContains(response, 'hx-swap-oob="outerHTML"')
|
||||||
|
self.assertEqual(
|
||||||
|
response.headers["HX-Trigger-After-Settle"],
|
||||||
|
"updated",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_other_users_cannot_apply_or_delete_a_preset(self):
|
||||||
|
self.client.force_login(self.other_user)
|
||||||
|
|
||||||
|
apply_response = self.client.get(
|
||||||
|
reverse("filter_preset_apply", args=[self.preset.pk]),
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
delete_response = self.client.post(
|
||||||
|
reverse("filter_preset_delete", args=[self.preset.pk]),
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(apply_response.status_code, 404)
|
||||||
|
self.assertEqual(delete_response.status_code, 404)
|
||||||
|
self.assertTrue(FilterPreset.objects.filter(pk=self.preset.pk).exists())
|
||||||
|
|
||||||
|
def test_delete_removes_the_current_users_preset(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("filter_preset_delete", args=[self.preset.pk]),
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertFalse(FilterPreset.objects.filter(pk=self.preset.pk).exists())
|
||||||
|
self.assertNotContains(response, self.preset.name)
|
||||||
|
|
||||||
|
def test_all_transactions_page_only_offers_the_current_users_presets(self):
|
||||||
|
FilterPreset.objects.create(
|
||||||
|
owner=self.other_user,
|
||||||
|
name="Other User Preset",
|
||||||
|
parameters={"type": ["IN"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("transactions_all_index"))
|
||||||
|
|
||||||
|
self.assertContains(response, self.preset.name)
|
||||||
|
self.assertNotContains(response, "Other User Preset")
|
||||||
|
self.assertContains(response, 'id="filter-presets"')
|
||||||
|
self.assertNotContains(response, 'href="./?')
|
||||||
|
self.assertContains(
|
||||||
|
response,
|
||||||
|
reverse("filter_preset_apply", args=[self.preset.pk]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_transactions_page_marks_a_filtered_query_active(self):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("transactions_all_index"),
|
||||||
|
{"type": ["IN", "EX"], "is_paid": ["0"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(response.context["filter_is_active"], True)
|
||||||
|
|
||||||
|
def test_all_transactions_page_does_not_mark_default_query_active(self):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("transactions_all_index"),
|
||||||
|
{
|
||||||
|
"type": ["IN", "EX"],
|
||||||
|
"is_paid": ["1", "0"],
|
||||||
|
"mute_status": ["active", "muted"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(response.context["filter_is_active"], False)
|
||||||
|
|
||||||
|
def test_monthly_page_renders_preset_controls(self):
|
||||||
|
other_preset = FilterPreset.objects.create(
|
||||||
|
owner=self.other_user,
|
||||||
|
name="Other Monthly Preset",
|
||||||
|
parameters={"type": ["IN"]},
|
||||||
|
)
|
||||||
|
response = self.client.get(reverse("monthly_overview", args=[8, 2026]))
|
||||||
|
|
||||||
|
self.assertContains(response, 'id="filter-presets"')
|
||||||
|
self.assertNotContains(response, 'href="./?')
|
||||||
|
self.assertContains(response, reverse("filter_preset_create"))
|
||||||
|
self.assertNotContains(response, other_preset.name)
|
||||||
@@ -6,6 +6,26 @@ urlpatterns = [
|
|||||||
path(
|
path(
|
||||||
"transactions/list/", views.transaction_all_list, name="transactions_all_list"
|
"transactions/list/", views.transaction_all_list, name="transactions_all_list"
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"transactions/filter-presets/create/",
|
||||||
|
views.filter_preset_create,
|
||||||
|
name="filter_preset_create",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"transactions/filter-presets/<int:preset_id>/apply/",
|
||||||
|
views.filter_preset_apply,
|
||||||
|
name="filter_preset_apply",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"transactions/filter-presets/<int:preset_id>/delete/",
|
||||||
|
views.filter_preset_delete,
|
||||||
|
name="filter_preset_delete",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"transactions/filter/clear/",
|
||||||
|
views.transaction_filter_clear,
|
||||||
|
name="transaction_filter_clear",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"transactions/trash/",
|
"transactions/trash/",
|
||||||
views.transactions_trash_can_index,
|
views.transactions_trash_can_index,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from apps.transactions.forms import (
|
|||||||
TransactionForm,
|
TransactionForm,
|
||||||
TransferForm,
|
TransferForm,
|
||||||
)
|
)
|
||||||
from apps.transactions.models import Transaction, TransactionAttachment
|
from apps.transactions.models import FilterPreset, Transaction, TransactionAttachment
|
||||||
from apps.transactions.utils.calculations import (
|
from apps.transactions.utils.calculations import (
|
||||||
calculate_account_totals,
|
calculate_account_totals,
|
||||||
calculate_currency_totals,
|
calculate_currency_totals,
|
||||||
@@ -23,7 +23,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Case, IntegerField, Q, Value, When
|
from django.db.models import Case, IntegerField, Q, Value, When
|
||||||
from django.http import FileResponse, Http404, HttpResponse, JsonResponse
|
from django.http import FileResponse, Http404, HttpResponse, JsonResponse, QueryDict
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -635,7 +635,92 @@ def transaction_all_index(request):
|
|||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"transactions/pages/transactions.html",
|
"transactions/pages/transactions.html",
|
||||||
{"filter": f, "order": order, "summary_tab": summary_tab},
|
{
|
||||||
|
"filter": f,
|
||||||
|
"filter_is_active": f.has_active_filters,
|
||||||
|
"filter_presets": FilterPreset.objects.filter(owner=request.user),
|
||||||
|
"order": order,
|
||||||
|
"summary_tab": summary_tab,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def filter_preset_create(request):
|
||||||
|
name = request.POST.get("name", "").strip()
|
||||||
|
if not name or len(name) > 100:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
parameters = {
|
||||||
|
key: request.POST.getlist(key)
|
||||||
|
for key in TransactionsFilter.base_filters
|
||||||
|
if key in request.POST
|
||||||
|
}
|
||||||
|
FilterPreset.objects.create(
|
||||||
|
owner=request.user,
|
||||||
|
name=name,
|
||||||
|
parameters=parameters,
|
||||||
|
)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"transactions/fragments/filter_presets.html",
|
||||||
|
{"filter_presets": FilterPreset.objects.filter(owner=request.user)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def filter_preset_apply(request, preset_id):
|
||||||
|
preset = get_object_or_404(FilterPreset, pk=preset_id, owner=request.user)
|
||||||
|
data = QueryDict(mutable=True)
|
||||||
|
for key, values in preset.parameters.items():
|
||||||
|
if key in TransactionsFilter.base_filters:
|
||||||
|
data.setlist(key, values)
|
||||||
|
|
||||||
|
transaction_filter = TransactionsFilter(data)
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"transactions/fragments/filter_form.html",
|
||||||
|
{
|
||||||
|
"filter": transaction_filter,
|
||||||
|
"filter_is_active": transaction_filter.has_active_filters,
|
||||||
|
"swap_filter_indicator": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.headers["HX-Trigger-After-Settle"] = "updated"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def transaction_filter_clear(request):
|
||||||
|
transaction_filter = TransactionsFilter(QueryDict())
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"transactions/fragments/filter_form.html",
|
||||||
|
{
|
||||||
|
"filter": transaction_filter,
|
||||||
|
"filter_is_active": False,
|
||||||
|
"swap_filter_indicator": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.headers["HX-Trigger-After-Settle"] = "updated"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def filter_preset_delete(request, preset_id):
|
||||||
|
get_object_or_404(FilterPreset, pk=preset_id, owner=request.user).delete()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"transactions/fragments/filter_presets.html",
|
||||||
|
{"filter_presets": FilterPreset.objects.filter(owner=request.user)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
@click="filterOpen = !filterOpen"
|
@click="filterOpen = !filterOpen"
|
||||||
:aria-expanded="filterOpen" id="filter-button"
|
:aria-expanded="filterOpen" id="filter-button"
|
||||||
title="{% translate 'Filter transactions' %}"
|
title="{% translate 'Filter transactions' %}"
|
||||||
_="on load or change from #filter
|
_="on change from #filter-container
|
||||||
-- Check if any filter has a non-default value
|
-- Check if any filter has a non-default value
|
||||||
set hasActiveFilter to false
|
set hasActiveFilter to false
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
add .hidden to #filter-active-indicator
|
add .hidden to #filter-active-indicator
|
||||||
end">
|
end">
|
||||||
<i class="fa-solid fa-filter fa-fw"></i>
|
<i class="fa-solid fa-filter fa-fw"></i>
|
||||||
<span id="filter-active-indicator" class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full hidden z-10"></span>
|
<span id="filter-active-indicator" class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full {% if not filter_is_active %}hidden {% endif %}z-10"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{# Search box #}
|
{# Search box #}
|
||||||
@@ -228,6 +228,8 @@
|
|||||||
show <.transaction/> in <#transactions-list/>
|
show <.transaction/> in <#transactions-list/>
|
||||||
when its textContent.toLowerCase() contains my value.toLowerCase()">
|
when its textContent.toLowerCase() contains my value.toLowerCase()">
|
||||||
|
|
||||||
|
{% include "transactions/fragments/filter_presets.html" %}
|
||||||
|
|
||||||
{# Order by icon dropdown #}
|
{# Order by icon dropdown #}
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-secondary join-item" type="button"
|
<button class="btn btn-secondary join-item" type="button"
|
||||||
@@ -270,21 +272,16 @@
|
|||||||
{# Filter transactions form #}
|
{# Filter transactions form #}
|
||||||
<div class="z-1" x-show="filterOpen" x-collapse x-cloak>
|
<div class="z-1" x-show="filterOpen" x-collapse x-cloak>
|
||||||
<div class="card card-body bg-base-200 mt-2">
|
<div class="card card-body bg-base-200 mt-2">
|
||||||
<div class="text-right">
|
{% include "transactions/fragments/filter_actions.html" %}
|
||||||
<button class="btn btn-outline btn-error btn-sm w-fit"
|
|
||||||
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form _="on change or submit or search trigger updated on window
|
{% include "transactions/fragments/filter_form.html" %}
|
||||||
install init_tom_select
|
|
||||||
install init_datepicker"
|
|
||||||
id="filter" class="mt-3">
|
|
||||||
{% crispy filter.form %}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<button class="btn btn-outline btn-error btn-sm w-fit"
|
<a class="btn btn-outline btn-error btn-sm w-fit"
|
||||||
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
href="{{ request.path }}"
|
||||||
|
hx-get="{% url 'transaction_filter_clear' %}"
|
||||||
|
hx-target="#filter"
|
||||||
|
hx-swap="outerHTML">{% translate 'Clear' %}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% translate "Save filter preset" as save_filter_preset_title %}
|
||||||
|
{% translate "Preset name" as preset_name_label %}
|
||||||
|
{% translate "Cancel" as cancel_label %}
|
||||||
|
{% translate "Save" as save_label %}
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<form id="save-filter-preset"
|
||||||
|
hx-post="{% url 'filter_preset_create' %}"
|
||||||
|
hx-include="#filter"
|
||||||
|
hx-target="#filter-presets"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<input id="filter-preset-name" type="hidden" name="name">
|
||||||
|
<button class="btn btn-outline btn-primary btn-sm" type="button"
|
||||||
|
data-title="{{ save_filter_preset_title }}"
|
||||||
|
data-input-label="{{ preset_name_label }}"
|
||||||
|
data-cancel-text="{{ cancel_label }}"
|
||||||
|
data-confirm-text="{{ save_label }}"
|
||||||
|
_="on click
|
||||||
|
call Swal.fire({title: @data-title,
|
||||||
|
input: 'text',
|
||||||
|
inputLabel: @data-input-label,
|
||||||
|
inputAttributes: {maxlength: '100'},
|
||||||
|
showCancelButton: true,
|
||||||
|
cancelButtonText: @data-cancel-text,
|
||||||
|
confirmButtonText: @data-confirm-text,
|
||||||
|
customClass: {
|
||||||
|
confirmButton: 'btn btn-primary me-3',
|
||||||
|
cancelButton: 'btn btn-error'
|
||||||
|
},
|
||||||
|
buttonsStyling: false})
|
||||||
|
if result.isConfirmed
|
||||||
|
set presetName to result.value.trim()
|
||||||
|
if presetName is not ''
|
||||||
|
set #filter-preset-name.value to presetName
|
||||||
|
trigger submit on #save-filter-preset
|
||||||
|
end
|
||||||
|
end">
|
||||||
|
<i class="fa-solid fa-bookmark fa-fw"></i>
|
||||||
|
{% translate 'Save preset' %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a class="btn btn-outline btn-error btn-sm"
|
||||||
|
href="{{ request.path }}"
|
||||||
|
hx-get="{% url 'transaction_filter_clear' %}"
|
||||||
|
hx-target="#filter"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{% translate 'Clear' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
<form _="on change or submit or search
|
||||||
|
if event.type is 'submit'
|
||||||
|
halt the event
|
||||||
|
end
|
||||||
|
trigger updated on window
|
||||||
|
end
|
||||||
|
install init_tom_select
|
||||||
|
install init_datepicker"
|
||||||
|
id="filter" class="mt-3">
|
||||||
|
{% crispy filter.form %}
|
||||||
|
</form>
|
||||||
|
{% if swap_filter_indicator %}
|
||||||
|
<span id="filter-active-indicator"
|
||||||
|
class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full {% if not filter_is_active %}hidden {% endif %}z-10"
|
||||||
|
hx-swap-oob="outerHTML"></span>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<div id="filter-presets">
|
||||||
|
<button class="btn btn-secondary join-item" type="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false"
|
||||||
|
title="{% translate 'Filter presets' %}">
|
||||||
|
<i class="fa-solid fa-bookmark fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end menu min-w-52">
|
||||||
|
{% for preset in filter_presets %}
|
||||||
|
<li class="group flex-row items-center">
|
||||||
|
<button class="grow justify-start"
|
||||||
|
type="button"
|
||||||
|
hx-get="{% url 'filter_preset_apply' preset.id %}"
|
||||||
|
hx-target="#filter"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{{ preset.name }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||||
|
type="button"
|
||||||
|
aria-label="{% blocktranslate with name=preset.name %}Delete {{ name }}{% endblocktranslate %}"
|
||||||
|
hx-post="{% url 'filter_preset_delete' preset.id %}"
|
||||||
|
hx-trigger="confirmed"
|
||||||
|
hx-target="#filter-presets"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
data-title="{% translate 'Delete filter preset?' %}"
|
||||||
|
data-text="{% blocktranslate with name=preset.name %}Delete “{{ name }}”?{% endblocktranslate %}"
|
||||||
|
data-confirm-text="{% translate 'Delete' %}"
|
||||||
|
_="install prompt_swal">
|
||||||
|
<i class="fa-solid fa-xmark fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="menu-disabled"><span>{% translate 'No saved presets' %}</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
@click="filterOpen = !filterOpen"
|
@click="filterOpen = !filterOpen"
|
||||||
:aria-expanded="filterOpen" id="filter-button"
|
:aria-expanded="filterOpen" id="filter-button"
|
||||||
title="{% translate 'Filter transactions' %}"
|
title="{% translate 'Filter transactions' %}"
|
||||||
_="on load or change from #filter
|
_="on change from #filter-container
|
||||||
-- Check if any filter has a non-default value
|
-- Check if any filter has a non-default value
|
||||||
set hasActiveFilter to false
|
set hasActiveFilter to false
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
add .hidden to #filter-active-indicator
|
add .hidden to #filter-active-indicator
|
||||||
end">
|
end">
|
||||||
<i class="fa-solid fa-filter fa-fw"></i>
|
<i class="fa-solid fa-filter fa-fw"></i>
|
||||||
<span id="filter-active-indicator" class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full hidden z-10"></span>
|
<span id="filter-active-indicator" class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full {% if not filter_is_active %}hidden {% endif %}z-10"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{# Search box #}
|
{# Search box #}
|
||||||
@@ -177,7 +177,8 @@
|
|||||||
show <.transaction/> in <#transactions-list/>
|
show <.transaction/> in <#transactions-list/>
|
||||||
when its textContent.toLowerCase() contains my value.toLowerCase()">
|
when its textContent.toLowerCase() contains my value.toLowerCase()">
|
||||||
|
|
||||||
{# Order by icon dropdown #}
|
{% include "transactions/fragments/filter_presets.html" %}
|
||||||
|
|
||||||
{# Order by icon dropdown #}
|
{# Order by icon dropdown #}
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-secondary join-item" type="button"
|
<button class="btn btn-secondary join-item" type="button"
|
||||||
@@ -220,21 +221,16 @@
|
|||||||
{# Filter transactions form #}
|
{# Filter transactions form #}
|
||||||
<div class="z-1" x-show="filterOpen" x-collapse x-cloak>
|
<div class="z-1" x-show="filterOpen" x-collapse x-cloak>
|
||||||
<div class="card card-body bg-base-200 mt-2">
|
<div class="card card-body bg-base-200 mt-2">
|
||||||
<div class="text-right">
|
{% include "transactions/fragments/filter_actions.html" %}
|
||||||
<button class="btn btn-outline btn-error btn-sm w-fit"
|
|
||||||
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form _="on change or submit or search trigger updated on window
|
{% include "transactions/fragments/filter_form.html" %}
|
||||||
install init_tom_select
|
|
||||||
install init_datepicker"
|
|
||||||
id="filter" class="mt-3">
|
|
||||||
{% crispy filter.form %}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<button class="btn btn-outline btn-error btn-sm w-fit"
|
<a class="btn btn-outline btn-error btn-sm w-fit"
|
||||||
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
href="{{ request.path }}"
|
||||||
|
hx-get="{% url 'transaction_filter_clear' %}"
|
||||||
|
hx-target="#filter"
|
||||||
|
hx-swap="outerHTML">{% translate 'Clear' %}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user