From a465912b5570aab5f022f54a9cf6a8e9b1573527 Mon Sep 17 00:00:00 2001 From: ki Date: Sat, 29 Aug 2026 17:44:58 +0400 Subject: [PATCH] BUG-06: Auto-Pack Undo via Snapshot + actionable Toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-Pack rief vorher layout_mod.pack() auf und schickte einen simplen success-Toast. Wer aus Versehen klickte, hatte alle manuellen Positionen verloren — kein Undo, kein Recovery. Fix: - packAll() speichert layoutItems als JSON-Snapshot in einer local closure (vor dem pack-Call). - Nach erfolgreichem Pack: toast() bekommt einen 4. Parameter action={label, onClick}. - Neues toast()-Feature: action-Button im Toast, clickbar trotz dismiss-on-click. Klick ruft onClick und dismissed den Toast. - Bei Undo-Klick: POST /api/layout mit den snapshot-Items → Server restored → renderGrid() → 'Layout wiederhergestellt'. - Modal-Text weist auf die 10s Undo-Möglichkeit hin. Vorteile: - Versehentlicher Klick auf Auto-Pack ist recoverable. - Kein sessionStorage-Bloat, kein Multi-Tab-Konflikt (Closure-Variable statt global Storage). - Toast-Pattern ist jetzt generisch — andere Aktionen (z.B. 'Snapshot wiederherstellen' aus Sidebar) können denselben Mechanismus nutzen. Beweis: tests/test_autopack_undo.js (8/8 grün) 1. Snapshot (JSON.stringify(layoutItems)) wird erstellt 2. Toast mit action={label:'Rückgängig'} 3. Undo ruft POST /api/layout mit snapshot-Items 4. Modal-Body erwähnt 'rückgängig' 5. CSS .toast-action Klasse vorhanden 6. toast() unterstützt 4-Args (action-Parameter) 7. Action-Button Click ruft onClick + dismiss 8. .toast-action hat pointer-events:auto Live-Test: GET /?demo=1 → 200, 90847 bytes toast-action: 6 occurrences (CSS + JS) Rückgängig: 1 mention JSON.stringify(layoutItems): 1 Closes #8 --- templates/index.html | 60 ++++++++++++++++++++++++++++++++++--- tests/test_autopack_undo.js | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 tests/test_autopack_undo.js diff --git a/templates/index.html b/templates/index.html index 1a963bf..fae7e95 100644 --- a/templates/index.html +++ b/templates/index.html @@ -797,6 +797,21 @@ .toast.info .toast-icon { color: var(--info); } .toast.warn .toast-icon { color: var(--warn); } .toast-text { flex: 1; min-width: 0; word-break: break-word; } + .toast-action { + flex: 0 0 auto; + padding: 4px 10px; + background: var(--surface-3); + border: 1px solid var(--border-light); + color: var(--fg); + border-radius: 4px; + font-size: 0.85em; + font-family: inherit; + cursor: pointer; + pointer-events: auto; /* BUG-06: Klick auf Action-Button muss trotz Toast-Click-Handler gehen */ + } + .toast-action:hover { background: var(--accent); color: white; border-color: var(--accent); } + .toast.success .toast-action { border-color: var(--success); color: var(--success); } + .toast.success .toast-action:hover { background: var(--success); color: var(--bg); } @keyframes toastIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } @@ -1135,12 +1150,25 @@ // ============ Toast-Helper ============ // Aufruf: toast("Item gelöscht", "success" | "error" | "info" | "warn") // Optional: toast(text, type, duration_ms) — duration 0 = manuell wegklicken - function toast(text, type = "info", duration = 3500) { + // Optional: toast(text, type, duration_ms, action={label, onClick}) + // — Toast wird mit action-Button gerendert, Click auf den Button ruft onClick + // auf und dismissed den Toast. + function toast(text, type = "info", duration = 3500, action = null) { const c = document.getElementById('toastContainer'); const el = document.createElement('div'); el.className = 'toast ' + type; const iconChar = { success: '✓', error: '✕', info: 'ⓘ', warn: '⚠' }[type] || 'ⓘ'; - el.innerHTML = `
${iconChar}
`; + if (action) { + el.innerHTML = `
${iconChar}
+
+ `; + el.querySelector('.toast-action').addEventListener('click', (e) => { + e.stopPropagation(); + try { action.onClick(); } finally { remove(); } + }); + } else { + el.innerHTML = `
${iconChar}
`; + } el.querySelector('.toast-text').textContent = text; let removed = false; const remove = () => { @@ -1631,17 +1659,41 @@ const ok = await modalConfirm({ icon: 'warn', title: 'Alle Items neu anordnen?', - body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren.', + body: 'Auto-Pack sortiert alle Widgets nach Größe in den 4×4-Grid. Bestehende manuelle Positionen gehen verloren. Du kannst den Schritt 10 Sekunden lang rückgängig machen.', confirmText: 'Neu anordnen', }); if (!ok) return; + // BUG-06: Snapshot der aktuellen Items in sessionStorage für Undo. + // server gibt neue Items zurück; wir können das alte Layout wiederherstellen. + const snapshot = JSON.stringify(layoutItems); const r = await fetch('/api/layout/pack', { method: 'POST' }); const j = await r.json(); if (!j.ok) { toast('Auto-Pack fehlgeschlagen: ' + (j.error || '?'), 'error', 5000); return; } layoutItems = j.items; renderGrid(); debouncedSave(); - toast('Layout automatisch angeordnet', 'success'); + // BUG-06: Actionable Toast mit Undo-Button + toast('Layout automatisch angeordnet', 'success', 10000, { + label: 'Rückgängig', + onClick: async () => { + try { + const restored = JSON.parse(snapshot); + // restore via normalen Save-Endpunkt + const r2 = await fetch('/api/layout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ items: restored }), + }); + const j2 = await r2.json(); + if (!j2.ok) { toast('Undo fehlgeschlagen: ' + (j2.error || '?'), 'error', 5000); return; } + layoutItems = j2.items; + renderGrid(); + toast('Layout wiederhergestellt', 'info', 3000); + } catch (err) { + toast('Undo Fehler: ' + err.message, 'error', 5000); + } + } + }); } // ============ Plugin-Configs ============ diff --git a/tests/test_autopack_undo.js b/tests/test_autopack_undo.js new file mode 100644 index 0000000..6112916 --- /dev/null +++ b/tests/test_autopack_undo.js @@ -0,0 +1,58 @@ +// BUG-06 Test: Auto-Pack Undo via Snapshot +// +// Erwartung: +// 1) packAll() speichert layoutItems als JSON vor dem Pack +// 2) Toast wird mit action={label:"Rückgängig", onClick} aufgerufen (statt nur Text) +// 3) onClick sendet POST /api/layout mit den snapshot-Items +// 4) Modal-Text erwähnt "10 Sekunden lang rückgängig" +// 5) CSS: .toast-action Klasse vorhanden +// 6) toast()-Funktion unterstützt action-Parameter (4-Args-Signatur) + +const fs = require('fs'); +const html = fs.readFileSync('templates/index.html', 'utf8'); +const js = html.match(/