Merge pull request #602

feat(insights): replace fixed overviews with a single arrangeable overview
This commit is contained in:
Herculino Trotta
2026-09-12 18:03:14 -03:00
committed by GitHub
9 changed files with 478 additions and 323 deletions
+6 -11
View File
@@ -30,19 +30,14 @@ urlpatterns = [
name="category_sum_by_currency",
),
path(
"insights/category-overview/",
views.category_overview,
name="category_overview",
"insights/overview/",
views.overview,
name="insights_overview",
),
path(
"insights/tag-overview/",
views.tag_overview,
name="tag_overview",
),
path(
"insights/entity-overview/",
views.entity_overview,
name="entity_overview",
"insights/overview/results/",
views.overview_results,
name="insights_overview_results",
),
path(
"insights/late-transactions/",
+36 -3
View File
@@ -8,15 +8,16 @@ from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction
# Grouping levels an overview can be built from. Any ordering of these keys is a
# valid hierarchy, which is what makes the categories, tags and entities
# overviews the same view with a different level order.
# Grouping levels the overview can be built from. Any ordering of these keys is
# a valid hierarchy, which is what lets the user arrange the chain freely.
LEVELS = {
"categories": {"field": "category", "name": "category__name"},
"tags": {"field": "tags", "name": "tags__name"},
"entities": {"field": "entities", "name": "entities__name"},
}
LEVEL_KEYS = tuple(LEVELS)
CURRENCY_FIELDS = (
"account__currency",
"account__currency__code",
@@ -234,3 +235,35 @@ def get_grouped_totals(
)
return result
def _known(values):
"""Drop anything that is not a level, and any repeat."""
levels = []
for value in values:
if value in LEVEL_KEYS and value not in levels:
levels.append(value)
return levels
def clean_chain(values):
"""
The full chain in the submitted order.
Every level always has a chip, switched on or not, so a short or unknown
submission is padded back out rather than rejected.
"""
chain = _known(values)
return chain + [key for key in LEVEL_KEYS if key not in chain]
def clean_levels(values, chain):
"""
The levels actually switched on, ordered by their place in ``chain``.
Taking the order from the chain rather than from the submission keeps the
hierarchy and the chips in step even if the two disagree. Never empty: the
first chip stands in, since an overview with no levels has nothing to show.
"""
enabled = set(_known(values))
return [key for key in chain if key in enabled] or list(chain[:1])
+92 -64
View File
@@ -4,7 +4,6 @@ from dateutil.relativedelta import relativedelta
from django.contrib.auth.decorators import login_required
from django.db.models import Sum
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
@@ -22,7 +21,12 @@ from apps.insights.utils.category_explorer import (
get_category_sums_by_account,
get_category_sums_by_currency,
)
from apps.insights.utils.overview import get_grouped_totals
from apps.insights.utils.overview import (
LEVEL_KEYS,
clean_chain,
clean_levels,
get_grouped_totals,
)
from apps.insights.utils.sankey import (
generate_sankey_data_by_account,
generate_sankey_data_by_currency,
@@ -33,9 +37,7 @@ from apps.insights.utils.month_by_month import get_month_by_month_data
from apps.transactions.models import TransactionCategory, Transaction
from apps.transactions.utils.calculations import calculate_currency_totals
# Labels for the grouping levels an overview can be built from. The overviews
# only differ by which level sits at the top, so everything user facing lives
# here instead of in three near identical views and templates.
# Labels for the grouping levels the overview can be built from.
OVERVIEW_LEVELS = {
"categories": {
"label": _("Categories"),
@@ -61,54 +63,6 @@ OVERVIEW_LEVELS = {
}
def _render_overview(request, levels, url_name):
"""Render the overview table/chart for ``levels``, top level first."""
session_prefix = f"insights_{levels[0]}_overview"
def setting(name, default, cast=None):
key = f"{session_prefix}_{name}"
if name in request.GET:
value = cast(request.GET[name]) if cast else request.GET[name]
request.session[key] = value
return value
return request.session.get(key, default)
view_type = setting("view_type", "table")
showing = setting("showing", "final")
show_level_2 = setting("show_level_2", True, cast=lambda value: value == "on")
show_level_3 = setting("show_level_3", False, cast=lambda value: value == "on")
if show_level_2:
depth = 3 if show_level_3 else 2
else:
depth = 1
total_table = get_grouped_totals(
transactions_queryset=get_transactions(request, include_silent=True),
levels=levels,
showing=showing,
ignore_empty=False,
depth=depth,
)
return render(
request,
"insights/fragments/overview/index.html",
{
"total_table": total_table,
"refresh_url": reverse(url_name),
"view_type": view_type,
"showing": showing,
"show_level_2": show_level_2,
"show_level_3": show_level_3,
"level_1": OVERVIEW_LEVELS[levels[0]],
"level_2": OVERVIEW_LEVELS[levels[1]],
"level_3": OVERVIEW_LEVELS[levels[2]],
"empty_message": OVERVIEW_LEVELS[levels[0]]["empty_message"],
},
)
@login_required
@require_http_methods(["GET"])
def index(request):
@@ -247,28 +201,102 @@ def category_sum_by_currency(request):
)
OVERVIEW_SESSION_PREFIX = "insights_overview"
# What a first visit shows: categories broken down by tags, as the old
# Categories Overview did, with entities available but switched off.
OVERVIEW_DEFAULT_LEVELS = ["categories", "tags"]
def _overview_setting(request, name, default):
"""Read a control from the query string, falling back to the last one used."""
key = f"{OVERVIEW_SESSION_PREFIX}_{name}"
if name in request.GET:
request.session[key] = request.GET[name]
return request.GET[name]
return request.session.get(key, default)
def _overview_chain(request):
"""The chip order and the switched on levels, from the request or session."""
chain_key = f"{OVERVIEW_SESSION_PREFIX}_chain"
levels_key = f"{OVERVIEW_SESSION_PREFIX}_levels"
if "chain" in request.GET:
chain = clean_chain(request.GET.getlist("chain"))
levels = clean_levels(request.GET.getlist("level"), chain)
request.session[chain_key] = chain
request.session[levels_key] = levels
else:
chain = clean_chain(request.session.get(chain_key, list(LEVEL_KEYS)))
levels = clean_levels(
request.session.get(levels_key, OVERVIEW_DEFAULT_LEVELS), chain
)
return chain, levels
@only_htmx
@login_required
@require_http_methods(["GET"])
def category_overview(request):
return _render_overview(
request, ("categories", "tags", "entities"), "category_overview"
def overview(request):
"""
One overview whose level chain the user arranges.
Renders only the controls; they stay put while the results below them
reload, so rearranging the chain never pulls the chips out from under the
pointer. The chips hold the order, which is why nothing here rewrites it.
"""
chain, levels = _overview_chain(request)
return render(
request,
"insights/fragments/overview/index.html",
{
"chips": [
{
"key": key,
"meta": OVERVIEW_LEVELS[key],
"enabled": key in levels,
"position": levels.index(key) + 1 if key in levels else "",
}
for key in chain
],
"view_type": _overview_setting(request, "view_type", "table"),
"showing": _overview_setting(request, "showing", "final"),
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def tag_overview(request):
return _render_overview(request, ("tags", "categories", "entities"), "tag_overview")
def overview_results(request):
"""The table or chart for the arrangement the controls submitted."""
chain, levels = _overview_chain(request)
view_type = _overview_setting(request, "view_type", "table")
showing = _overview_setting(request, "showing", "final")
total_table = get_grouped_totals(
transactions_queryset=get_transactions(request, include_silent=True),
levels=levels,
showing=showing,
ignore_empty=False,
depth=len(levels),
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def entity_overview(request):
return _render_overview(
request, ("entities", "categories", "tags"), "entity_overview"
return render(
request,
"insights/fragments/overview/_results.html",
{
"total_table": total_table,
"view_type": view_type,
"showing": showing,
"level_1": OVERVIEW_LEVELS[levels[0]],
"level_2": OVERVIEW_LEVELS[levels[1]] if len(levels) > 1 else None,
"level_3": OVERVIEW_LEVELS[levels[2]] if len(levels) > 2 else None,
"empty_message": OVERVIEW_LEVELS[levels[0]]["empty_message"],
},
)
@@ -0,0 +1,195 @@
{% load i18n %}
{% if total_table %}
{% if view_type == "table" %}
<div class="card bg-base-100 card-border">
<div class="card-body">
<c-config.search></c-config.search>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th scope="col">{{ level_1.singular }}</th>
<th scope="col">{% trans 'Income' %}</th>
<th scope="col">{% trans 'Expense' %}</th>
<th scope="col">{% trans 'Total' %}</th>
</tr>
</thead>
<tbody>
{% for item in total_table.values %}
{# Top level row #}
{% include "insights/fragments/overview/_row.html" with node=item is_root=True row_class="font-semibold" empty_label=level_1.empty search_path=item.search_path %}
{# Second level rows #}
{% if level_2 %}
{% for child in item.children.values %}
{% if child.name or item.children.values|length > 1 %}
{% include "insights/fragments/overview/_row.html" with node=child row_class="bg-base-200" indent="ps-6" icon=level_2.icon empty_label=level_2.empty search_path=child.search_path %}
{# Third level rows #}
{% if level_3 %}
{% for grandchild in child.children.values %}
{% if grandchild.name or child.children.values|length > 1 %}
{% include "insights/fragments/overview/_row.html" with node=grandchild row_class="bg-base-300" indent="ps-10" icon=level_3.icon empty_label=level_3.empty search_path=grandchild.search_path %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% elif view_type == "bars" %}
<div class="card bg-base-100 card-border">
<div class="card-body">
<div class="chart-container relative h-[75vh] w-full" _="init call setupChart() end">
<canvas id="overviewChart"></canvas>
</div>
</div>
</div>
{{ total_table|json_script:"overviewData" }}
<script>
function setupChart() {
var rawData = JSON.parse(document.getElementById('overviewData').textContent);
var emptyLabel = "{{ level_1.empty|escapejs }}";
var items = [];
var currencyDetails = {};
var currencyData = {};
// Pass 1: collect the top level rows and the currencies in play
Object.values(rawData).forEach(item => {
var itemName = item.name === null ? emptyLabel : item.name;
if (!items.includes(itemName)) {
items.push(itemName);
}
if (item.currencies) {
Object.values(item.currencies).forEach(curr => {
var details = curr.currency;
if (details && details.code && !currencyDetails[details.code]) {
var decimals = parseInt(details.decimal_places, 10);
currencyDetails[details.code] = {
code: details.code,
name: details.name || details.code,
prefix: details.prefix || '',
suffix: details.suffix || '',
decimal_places: !isNaN(decimals) && decimals >= 0 ? decimals : 2
};
}
});
}
});
Object.keys(currencyDetails).forEach(code => {
currencyData[code] = new Array(items.length).fill(null);
});
// Pass 2: fill one series per currency, leaving gaps as null
Object.values(rawData).forEach(item => {
var itemName = item.name === null ? emptyLabel : item.name;
var itemIndex = items.indexOf(itemName);
if (itemIndex === -1) return;
if (item.currencies) {
Object.values(item.currencies).forEach(curr => {
var code = curr.currency?.code;
if (code && currencyData[code]) {
var value = parseFloat(curr.shown.total);
currencyData[code][itemIndex] = !isNaN(value) ? value : null;
}
});
}
});
var datasets = Object.keys(currencyDetails).map(code => {
return {
label: currencyDetails[code].name,
data: currencyData[code],
currencyCode: code,
borderWidth: 1
};
});
new Chart(document.getElementById('overviewChart'),
{
type: 'bar',
data: {
labels: items,
datasets: datasets
},
options: {
indexAxis: 'y',
responsive: true,
interaction: {
intersect: false,
mode: 'nearest',
axis: "y"
},
maintainAspectRatio: false,
plugins: {
title: {
display: false
},
tooltip: {
callbacks: {
label: function (context) {
const details = currencyDetails[context.dataset.currencyCode];
const value = context.parsed.x; // 'x' because indexAxis is 'y'
if (value === null || value === undefined || !details) {
return null;
}
let formattedValue = '';
try {
formattedValue = new Intl.NumberFormat(undefined, {
minimumFractionDigits: details.decimal_places,
maximumFractionDigits: details.decimal_places,
}).format(value);
} catch (e) {
formattedValue = value.toFixed(details.decimal_places);
}
return `${details.prefix}${formattedValue}${details.suffix}`;
}
}
},
legend: {
position: 'top',
}
},
scales: {
x: {
stacked: true,
type: 'linear',
title: {
display: true,
text: '{% trans 'Total' %}'
},
ticks: {
callback: function (value) {
return value.toLocaleString();
}
}
},
y: {
stacked: true,
title: {
display: false
}
}
}
}
});
}
</script>
{% endif %}
{% else %}
<c-msg.empty title="{{ empty_message }}"></c-msg.empty>
{% endif %}
@@ -1,7 +1,11 @@
{% load i18n %}
{% comment %}
Overview insight: the level chain is arranged here instead of being baked
into the view. Only #overview-results reloads when something changes, so the
controls never get pulled out from under the pointer.
{% endcomment %}
<div hx-get="{{ refresh_url }}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML"
hx-include="#picker-form, #picker-type, #view-type, #show-level-2, #showing, #show-level-3">
<div>
<div class="h-full text-center mb-4">
<div class="tabs tabs-box mx-auto w-fit" role="group" id="view-type" _="on change trigger updated">
<label class="tab">
@@ -28,37 +32,36 @@
</label>
</div>
</div>
<div class="my-3 flex flex-col gap-3 md:flex-row justify-between">
<div class="flex gap-4">
{% if view_type == 'table' %}
<div id="show-level-2">
<label class="label">
<input type="hidden" name="show_level_2" value="off">
<input type="checkbox" class="toggle toggle-primary toggle-sm" id="show-level-2-switch" name="show_level_2"
_="on change trigger updated" {% if show_level_2 %}checked{% endif %}>
<span>
{{ level_2.label }}
</span>
<c-ui.help-icon
content="{% trans 'Transaction amounts associated with multiple tags or entities will be counted once for each one of them' %}"
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
</label>
</div>
<div id="show-level-3" class="{% if not show_level_2 %}hidden{% endif %}">
<label class="label">
<input type="hidden" name="show_level_3" value="off">
<input type="checkbox" class="toggle toggle-primary toggle-sm" id="show-level-3-switch"
name="show_level_3"
_="on change trigger updated" {% if show_level_3 %}checked{% endif %}>
<span>
{{ level_3.label }}
</span>
<c-ui.help-icon
content="{% trans 'Transaction amounts associated with multiple tags or entities will be counted once for each one of them' %}"
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
</label>
</div>
{% endif %}
<div class="my-3 flex flex-col gap-3 md:flex-row md:items-center justify-between">
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2" id="level-chain"
x-data
x-sort.ghost="levelChainSorted()"
x-sort:config="{ ghostClass: 'opacity-40' }"
data-last-level-title="{% trans 'At least one level has to stay on' %}">
{% for chip in chips %}
{# The hidden inputs sit outside .join: it rounds its end from :last-child, which they would otherwise be #}
<div class="level-chip inline-flex{% if not chip.enabled %} level-off{% endif %}" data-level="{{ chip.key }}">
{# --radius-field is what .join reads for its outer corners, so overriding it here turns the chip into a pill #}
<div class="join bg-base-200 rounded-full [--radius-field:9999px]">
<button class="join-item btn btn-sm btn-ghost ps-3 pe-2 cursor-grab active:cursor-grabbing" type="button"
x-sort:handle data-handle
aria-label="{% trans 'Drag to reorder, or use the left and right arrow keys' %}"><i
class="fa-solid fa-grip-vertical fa-fw"></i></button>
<button class="join-item btn btn-sm pe-4 gap-2" type="button" data-toggle
aria-label="{{ chip.meta.label }}">
<span class="level-position badge badge-xs badge-neutral">{{ chip.position }}</span>
<span class="level-label"><i class="{{ chip.meta.icon }} fa-fw me-1"></i>{{ chip.meta.label }}</span>
</button>
</div>
<input type="hidden" name="chain" value="{{ chip.key }}">
<input type="hidden" name="level" value="{{ chip.key }}" {% if not chip.enabled %}disabled{% endif %}>
</div>
{% endfor %}
</div>
<c-ui.help-icon
content="{% trans 'Transaction amounts associated with multiple tags or entities will be counted once for each one of them' %}"
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
</div>
<div class="join" role="group" id="showing" _="on change trigger updated">
<input type="radio" class="join-item btn btn-outline btn-primary btn-sm" name="showing" id="showing-projected"
@@ -74,210 +77,102 @@
{% if showing == 'final' %}checked{% endif %}>
</div>
</div>
{% if total_table %}
{% if view_type == "table" %}
<div class="card bg-base-100 card-border">
<div class="card-body">
<c-config.search></c-config.search>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th scope="col">{{ level_1.singular }}</th>
<th scope="col">{% trans 'Income' %}</th>
<th scope="col">{% trans 'Expense' %}</th>
<th scope="col">{% trans 'Total' %}</th>
</tr>
</thead>
<tbody>
{% for item in total_table.values %}
{# Top level row #}
{% include "insights/fragments/overview/_row.html" with node=item is_root=True row_class="font-semibold" empty_label=level_1.empty search_path=item.search_path %}
{# Second level rows #}
{% if show_level_2 %}
{% for child in item.children.values %}
{% if child.name or item.children.values|length > 1 %}
{% include "insights/fragments/overview/_row.html" with node=child row_class="bg-base-200" indent="ps-6" icon=level_2.icon empty_label=level_2.empty search_path=child.search_path %}
<div id="overview-results" class="show-loading"
hx-get="{% url 'insights_overview_results' %}"
hx-trigger="load, updated from:window"
hx-include="#picker-form, #picker-type, #view-type, #level-chain, #showing"
hx-sync="this:replace"></div>
{# Third level rows #}
{% if show_level_3 %}
{% for grandchild in child.children.values %}
{% if grandchild.name or child.children.values|length > 1 %}
{% include "insights/fragments/overview/_row.html" with node=grandchild row_class="bg-base-300" indent="ps-10" icon=level_3.icon empty_label=level_3.empty search_path=grandchild.search_path %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% elif view_type == "bars" %}
<div class="card bg-base-100 card-border">
<div class="card-body">
<div class="chart-container relative h-[75vh] w-full" _="init call setupChart() end">
<canvas id="overviewChart"></canvas>
</div>
</div>
</div>
{{ total_table|json_script:"overviewData" }}
<script>
function setupChart() {
var rawData = JSON.parse(document.getElementById('overviewData').textContent);
// --- Dynamic Data Processing ---
var items = [];
var currencyDetails = {}; // Stores details like { BRL: {code: 'BRL', name: 'Real', ...}, ... }
var currencyData = {}; // Stores data arrays like { BRL: [val1, null, val3,...], ... }
// Pass 1: Collect items and currency details
Object.values(rawData).forEach(item => {
var itemName = item.name === null ? "{{ level_1.empty|escapejs }}" : item.name;
if (!items.includes(itemName)) {
items.push(itemName);
}
if (item.currencies) {
Object.values(item.currencies).forEach(curr => {
var details = curr.currency;
if (details && details.code && !currencyDetails[details.code]) {
var decimals = parseInt(details.decimal_places, 10);
currencyDetails[details.code] = {
code: details.code,
name: details.name || details.code,
prefix: details.prefix || '',
suffix: details.suffix || '',
// Ensure decimal_places is a non-negative integer
decimal_places: !isNaN(decimals) && decimals >= 0 ? decimals : 2
};
}
});
}
<script>
// The chips are the source of truth for the chain: their DOM order is the
// hierarchy and their hidden inputs are what gets submitted. Dragging is
// therefore instant, and the server only reads the result back.
function setupLevelChain(root) {
function refresh() {
var chips = Array.prototype.slice.call(root.querySelectorAll('.level-chip'));
var enabled = chips.filter(function (chip) {
return !chip.classList.contains('level-off');
});
// Initialize data structure for each currency with nulls
Object.keys(currencyDetails).forEach(code => {
currencyData[code] = new Array(items.length).fill(null);
chips.forEach(function (chip) {
var off = chip.classList.contains('level-off');
var toggle = chip.querySelector('[data-toggle]');
var label = chip.querySelector('.level-label');
var position = chip.querySelector('.level-position');
chip.querySelector('input[name="level"]').disabled = off;
// An off level reads as daisyUI's disabled button, but stays
// clickable. Using .btn-disabled would set pointer-events:none
// and strand it in the off state.
toggle.classList.toggle('btn-primary', !off);
toggle.classList.toggle('btn-ghost', off);
label.classList.toggle('line-through', off);
label.classList.toggle('opacity-50', off);
chip.classList.toggle('opacity-80', off);
position.textContent = off ? '' : (enabled.indexOf(chip) + 1);
position.hidden = off;
// Same reasoning for the last one standing: mark it refused
// rather than disabled, so it still looks switched on.
var last = !off && enabled.length === 1;
toggle.setAttribute('aria-disabled', last ? 'true' : 'false');
toggle.classList.toggle('cursor-not-allowed', last);
toggle.title = last ? (root.dataset.lastLevelTitle || '') : '';
});
// Pass 2: Populate data arrays (store all valid numbers now)
Object.values(rawData).forEach(item => {
var itemName = item.name === null ? "{{ level_1.empty|escapejs }}" : item.name;
var itemIndex = items.indexOf(itemName);
if (itemIndex === -1) return;
if (item.currencies) {
Object.values(item.currencies).forEach(curr => {
var code = curr.currency?.code;
if (code && currencyData[code]) {
var value = parseFloat(curr.shown.total);
// Store the number if it's valid, otherwise keep null
currencyData[code][itemIndex] = !isNaN(value) ? value : null;
}
});
}
});
// --- Dynamic Chart Configuration ---
var datasets = Object.keys(currencyDetails).map((code, index) => {
return {
label: currencyDetails[code].name, // Use currency name for the legend label
data: currencyData[code],
currencyCode: code, // Store code for easy lookup in tooltip
borderWidth: 1
};
});
new Chart(document.getElementById('overviewChart'),
{
type: 'bar',
data: {
labels: items,
datasets: datasets
},
options: {
indexAxis: 'y',
responsive: true,
interaction: {
intersect: false,
mode: 'nearest',
axis: "y"
},
maintainAspectRatio: false,
plugins: {
title: {
display: false
},
tooltip: {
callbacks: {
label: function (context) {
const dataset = context.dataset;
const currencyCode = dataset.currencyCode;
const details = currencyDetails[currencyCode];
const value = context.parsed.x; // Use 'x' because indexAxis is 'y'
if (value === null || value === undefined || !details) {
// Display the item name if the value is null/undefined
return null;
}
let formattedValue = '';
try {
// Use Intl.NumberFormat for ALL values, configured with locale and exact decimal places
formattedValue = new Intl.NumberFormat(undefined, {
minimumFractionDigits: details.decimal_places,
maximumFractionDigits: details.decimal_places,
// Do NOT use style: 'currency' here, as we add prefix/suffix manually
}).format(value);
} catch (e) {
formattedValue = value.toFixed(details.decimal_places);
}
// Return label with currency name and formatted value including prefix/suffix
return `${details.prefix}${formattedValue}${details.suffix}`;
}
}
},
legend: {
position: 'top',
}
},
scales: {
x: {
stacked: true,
type: 'linear',
title: {
display: true,
text: '{% trans 'Total' %}'
},
ticks: {
// Format ticks using the detected locale
callback: function (value, index, ticks) {
return value.toLocaleString();
}
}
},
y: {
stacked: true,
title: {
display: false
}
}
}
}
});
}
</script>
{% endif %}
{% else %}
<c-msg.empty title="{{ empty_message }}"></c-msg.empty>
{% endif %}
function commit() {
refresh();
window.dispatchEvent(new CustomEvent('updated'));
}
function move(chip, step) {
var sibling = step < 0 ? chip.previousElementSibling : chip.nextElementSibling;
if (!sibling) return false;
if (step < 0) {
root.insertBefore(chip, sibling);
} else {
root.insertBefore(sibling, chip);
}
return true;
}
root.addEventListener('click', function (event) {
var toggle = event.target.closest('[data-toggle]');
if (!toggle || !root.contains(toggle)) return;
if (toggle.getAttribute('aria-disabled') === 'true') return;
toggle.closest('.level-chip').classList.toggle('level-off');
commit();
});
// Dragging is mouse and touch only, so the handle also reorders with
// the arrow keys rather than leaving the chain keyboard inaccessible.
root.addEventListener('keydown', function (event) {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
var handle = event.target.closest('[data-handle]');
if (!handle || !root.contains(handle)) return;
if (!move(handle.closest('.level-chip'), event.key === 'ArrowLeft' ? -1 : 1)) return;
event.preventDefault();
handle.focus();
commit();
});
// Reached from the x-sort handler once Sortable has moved the chip.
root.levelChainCommit = commit;
refresh();
}
function levelChainSorted() {
var root = document.getElementById('level-chain');
if (root && root.levelChainCommit) root.levelChainCommit();
}
// Wired up here rather than with a hyperscript init, so it does not
// depend on which of the two runs first after an htmx swap.
setupLevelChain(document.getElementById('level-chain'));
</script>
</div>
+2 -12
View File
@@ -90,18 +90,8 @@
</button>
<button class="btn btn-ghost btn-free justify-start text-start" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'category_overview' %}"
>{% trans 'Categories Overview' %}
</button>
<button class="btn btn-ghost btn-free justify-start text-start" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'tag_overview' %}"
>{% trans 'Tags Overview' %}
</button>
<button class="btn btn-ghost btn-free justify-start text-start" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'entity_overview' %}"
>{% trans 'Entities Overview' %}
hx-get="{% url 'insights_overview' %}"
>{% trans 'Overview' %}
</button>
</div>
</div>
+16
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@alpinejs/collapse": "^3.15.12",
"@alpinejs/mask": "^3.15.12",
"@alpinejs/sort": "^3.17.2",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.2.0",
"@popperjs/core": "^2.11.8",
@@ -52,6 +53,15 @@
"integrity": "sha512-FcOVQp+tsIiBeNwWsH1BjAhJa+R/b6Tv6sPfSMRZlp1e/4w4H44Kagd9Aq86xpFgeI5dibJil2TG0+3LqB4JoA==",
"license": "MIT"
},
"node_modules/@alpinejs/sort": {
"version": "3.17.2",
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.17.2.tgz",
"integrity": "sha512-72v63zuFkepgO2nwi8wNrD1z6pAY3S+d+lCwSZ9Q39hkIFISxPXKffEh3qtTu7Dzlc98EYLz4kVS/pxp3mdw5g==",
"license": "MIT",
"dependencies": {
"sortablejs": "^1.15.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
@@ -1950,6 +1960,12 @@
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"license": "MIT"
},
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+1
View File
@@ -18,6 +18,7 @@
"dependencies": {
"@alpinejs/collapse": "^3.15.12",
"@alpinejs/mask": "^3.15.12",
"@alpinejs/sort": "^3.17.2",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.2.0",
"@popperjs/core": "^2.11.8",
+2
View File
@@ -3,6 +3,7 @@ import './_htmx.js';
import Alpine from "alpinejs";
import mask from '@alpinejs/mask';
import collapse from '@alpinejs/collapse'
import sort from '@alpinejs/sort';
import { create, all } from 'mathjs';
window.Alpine = Alpine;
@@ -12,6 +13,7 @@ window.math = create(all, {
Alpine.plugin(mask);
Alpine.plugin(collapse);
Alpine.plugin(sort);
Alpine.start();
/**