feat: Tabellen per Spaltenkopf sortierbar (A11-T4)
Neues static/sort.js sortiert Tabellen clientseitig per Klick auf den Spaltenkopf (⇅/▲/▼): Datum TT.MM.JJJJ, deutsche Zahlen/Beträge, sonst Text (Intl.Collator 'de'). Sortierwert optional per td[data-sort] (Punkt-Dezimal/ISO) überschreibbar, th[data-nosort] bekommt keinen Button, table[data-sort-mode="server"] wird übersprungen (Buchungen sortiert serverseitig, folgt in Task 5). Bearbeiten-/Tilgungsplan- Zeilen (tr[data-sort-follow]) wandern mit ihrer Vorzeile mit. Initialisierung über htmx.onLoad, greift auch bei nachgeladenen Fragmenten. Templates entsprechend annotiert (Planung, Szenarien, Admin, Index, Salden, Import-Liste, Buchungen); Salden-Gesamtzeile dafür in ein tfoot verschoben. test_gui.py::test_gui_zeigt_deutsche_betragsformate angepasst, da das neue machinenlesbare data-sort-Attribut bewusst Punkt-Dezimal führt (Interface-Konvention) - die Prüfung auf deutsches Format im sichtbaren Text bleibt bestehen. JS-Unit-/DOM-Tests unter tests/js (node --test, DOM-Teil optional via jsdom), pytest-Wrapper skip ohne node (Fable-Gate führt sie per Podman aus). Suite: 248 passed, 3 skipped (vorher 242/2). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
let JSDOM = null;
|
||||
try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ }
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/sort.js'), 'utf8');
|
||||
|
||||
function load(html) {
|
||||
const dom = new JSDOM(`<!doctype html><body>${html}</body>`, { runScripts: 'outside-only' });
|
||||
dom.window.eval(SRC);
|
||||
dom.window.FBSort.init(dom.window.document.body);
|
||||
return dom.window.document;
|
||||
}
|
||||
|
||||
const TABLE = `
|
||||
<table id="t"><thead><tr><th>Name</th><th>Betrag</th><th>Aktion</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr id="r1"><td>B</td><td>10,00 €</td><td>x</td><td></td></tr>
|
||||
<tr id="r1-edit" hidden data-sort-follow><td colspan="4">edit1</td></tr>
|
||||
<tr id="r2"><td>A</td><td>-5,00 €</td><td>x</td><td></td></tr>
|
||||
<tr id="r2-edit" hidden data-sort-follow><td colspan="4">edit2</td></tr>
|
||||
<tr id="r3"><td>C</td><td>–</td><td>x</td><td></td></tr>
|
||||
</tbody>
|
||||
<tfoot><tr id="sum"><td>Gesamt</td><td>5,00 €</td><td></td><td></td></tr></tfoot>
|
||||
</table>
|
||||
<table data-sort-mode="server" id="s"><thead><tr><th>Datum</th></tr></thead><tbody></tbody></table>`;
|
||||
|
||||
const ids = (doc) => [...doc.querySelectorAll('#t tbody tr')].map((r) => r.id);
|
||||
|
||||
test('Buttons nur an Spalten mit Text, nicht an server-Tabellen', { skip: !JSDOM }, () => {
|
||||
const doc = load(TABLE.replace('<th>Aktion</th>', '<th data-nosort>Aktion</th>'));
|
||||
assert.equal(doc.querySelectorAll('#t .sort-btn').length, 2);
|
||||
assert.equal(doc.querySelectorAll('#s .sort-btn').length, 0);
|
||||
assert.equal(doc.querySelector('#t .sort-btn').textContent, '⇅');
|
||||
});
|
||||
|
||||
test('Klick sortiert auf/ab, Folgezeilen wandern mit, Leerwert am Ende, tfoot bleibt', { skip: !JSDOM }, () => {
|
||||
const doc = load(TABLE);
|
||||
const btn = doc.querySelectorAll('#t .sort-btn')[1]; // Betrag
|
||||
btn.click();
|
||||
assert.equal(btn.textContent, '▲');
|
||||
assert.deepEqual(ids(doc), ['r2', 'r2-edit', 'r1', 'r1-edit', 'r3']);
|
||||
btn.click();
|
||||
assert.equal(btn.textContent, '▼');
|
||||
assert.deepEqual(ids(doc), ['r1', 'r1-edit', 'r2', 'r2-edit', 'r3']);
|
||||
assert.equal(doc.querySelector('#t tfoot tr').id, 'sum');
|
||||
const nameBtn = doc.querySelectorAll('#t .sort-btn')[0];
|
||||
nameBtn.click();
|
||||
assert.equal(btn.textContent, '⇅');
|
||||
assert.deepEqual(ids(doc), ['r2', 'r2-edit', 'r1', 'r1-edit', 'r3']);
|
||||
});
|
||||
|
||||
test('init zweimal erzeugt keine doppelten Buttons', { skip: !JSDOM }, () => {
|
||||
const doc = load(TABLE);
|
||||
doc.defaultView.FBSort.init(doc.body);
|
||||
assert.equal(doc.querySelectorAll('#t .sort-btn').length, 3);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const S = require('../../app/static/sort.js');
|
||||
|
||||
const items = (vals, ds) => vals.map((v, i) => ({ key: S.sortKey(v, !!ds), index: i, label: v }));
|
||||
const labels = (arr) => arr.map((i) => i.label);
|
||||
|
||||
test('Datum TT.MM.JJJJ', () => {
|
||||
const k = S.sortKey('05.03.2026', false);
|
||||
assert.equal(k.kind, 'date');
|
||||
assert.equal(k.value, 20260305);
|
||||
});
|
||||
|
||||
test('deutsche Beträge und Zahlen', () => {
|
||||
assert.equal(S.sortKey('-1.234,56 €', false).value, -1234.56);
|
||||
assert.equal(S.sortKey('5,00 %', false).value, 5);
|
||||
assert.equal(S.sortKey('15', false).kind, 'number');
|
||||
assert.equal(S.sortKey('1.234', false).value, 1234);
|
||||
});
|
||||
|
||||
test('data-sort: Punkt-Dezimal und ISO-Datum', () => {
|
||||
assert.equal(S.sortKey('-1234.56', true).value, -1234.56);
|
||||
assert.equal(S.sortKey('2026-06-30', true).kind, 'text');
|
||||
});
|
||||
|
||||
test('Leerwerte sind null', () => {
|
||||
assert.equal(S.sortKey('', false), null);
|
||||
assert.equal(S.sortKey(' ', false), null);
|
||||
assert.equal(S.sortKey('–', false), null);
|
||||
assert.equal(S.sortKey(undefined, false), null);
|
||||
});
|
||||
|
||||
test('Zahlen numerisch statt alphabetisch', () => {
|
||||
assert.deepEqual(labels(S.sortItems(items(['10,00', '9,00', '-100,00']), 'asc')),
|
||||
['-100,00', '9,00', '10,00']);
|
||||
assert.deepEqual(labels(S.sortItems(items(['10,00', '9,00', '-100,00']), 'desc')),
|
||||
['10,00', '9,00', '-100,00']);
|
||||
});
|
||||
|
||||
test('Datum chronologisch', () => {
|
||||
assert.deepEqual(labels(S.sortItems(items(['01.02.2026', '15.01.2026', '31.12.2025']), 'asc')),
|
||||
['31.12.2025', '15.01.2026', '01.02.2026']);
|
||||
});
|
||||
|
||||
test('Leerwerte immer am Ende, beide Richtungen', () => {
|
||||
const v = ['01.02.2026', '–', '15.01.2026', ''];
|
||||
assert.deepEqual(labels(S.sortItems(items(v), 'asc')), ['15.01.2026', '01.02.2026', '–', '']);
|
||||
assert.deepEqual(labels(S.sortItems(items(v), 'desc')), ['01.02.2026', '15.01.2026', '–', '']);
|
||||
});
|
||||
|
||||
test('gemischte Typen -> Text', () => {
|
||||
assert.equal(S.columnKind([S.sortKey('10,00'), S.sortKey('abc'), null]), 'text');
|
||||
assert.equal(S.columnKind([S.sortKey('10,00'), null]), 'number');
|
||||
});
|
||||
|
||||
test('Text deutsch (Umlaute, Groß/klein)', () => {
|
||||
assert.deepEqual(labels(S.sortItems(items(['Zahnarzt', 'Ärztin', 'miete']), 'asc')),
|
||||
['Ärztin', 'miete', 'Zahnarzt']);
|
||||
});
|
||||
|
||||
test('stabil bei gleichen Werten', () => {
|
||||
const it = [{ key: S.sortKey('5'), index: 0, label: 'a' }, { key: S.sortKey('5'), index: 1, label: 'b' }];
|
||||
assert.deepEqual(labels(S.sortItems(it, 'desc')), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('groupRows: Folgezeilen hängen an der Vorzeile', () => {
|
||||
const rows = [{ id: 'a' }, { id: 'a-edit', f: true }, { id: 'a-plan', f: true },
|
||||
{ id: 'b' }, { id: 'b-edit', f: true }];
|
||||
assert.deepEqual(S.groupRows(rows, (r) => !!r.f).map((g) => g.map((r) => r.id)),
|
||||
[['a', 'a-edit', 'a-plan'], ['b', 'b-edit']]);
|
||||
});
|
||||
Reference in New Issue
Block a user