From a66bd2b05c041c8e899bc46607f5735a346778dc Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 22:26:46 +0200 Subject: [PATCH] feat(ux): zentrales deleteWithUndo + Undo-Toast in Birthdays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deleteWithUndo in ux.js: onDelete ausführen, Undo-Toast anzeigen. Birthdays migriert; Contacts/Notes/Meals haben bereits optimistische Undo-Logik. --- public/pages/birthdays.js | 25 ++++++++++++++++++------- public/utils/ux.js | 21 +++++++++++++++++++++ test-ux-utils.js | 32 +++++++++++++++++++++++++++++--- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/public/pages/birthdays.js b/public/pages/birthdays.js index 08f707f..852415f 100644 --- a/public/pages/birthdays.js +++ b/public/pages/birthdays.js @@ -1,6 +1,6 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; -import { stagger } from '/utils/ux.js'; +import { stagger, deleteWithUndo } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -378,15 +378,26 @@ function openBirthdayModal({ mode, birthday = null }) { async function deleteBirthday(id, name) { if (!await confirmModal(t('birthdays.deleteConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return; - await api.delete(`/birthdays/${id}`); - state.birthdays = state.birthdays - .filter((birthday) => birthday.id !== id) - .sort((a, b) => a.name.localeCompare(b.name)); - state.upcoming = state.upcoming.filter((birthday) => birthday.id !== id); + const birthday = state.birthdays.find((b) => b.id === id); + state.birthdays = state.birthdays.filter((b) => b.id !== id).sort((a, b) => a.name.localeCompare(b.name)); + state.upcoming = state.upcoming.filter((b) => b.id !== id); renderUpcoming(); renderSuggestions(); renderList(); - window.oikos?.showToast(t('birthdays.deletedToast'), 'success'); + await deleteWithUndo({ + onDelete: async () => { await api.delete(`/birthdays/${id}`); }, + onUndo: async () => { + if (birthday) { + state.birthdays = [...state.birthdays, birthday].sort((a, b) => a.name.localeCompare(b.name)); + state.upcoming = [...state.upcoming, birthday]; + renderUpcoming(); + renderSuggestions(); + renderList(); + } + }, + toastMessage: t('birthdays.deletedToast'), + toastType: 'success', + }); } export async function render(container) { diff --git a/public/utils/ux.js b/public/utils/ux.js index 5354ae4..f15ba71 100644 --- a/public/utils/ux.js +++ b/public/utils/ux.js @@ -40,3 +40,24 @@ export function vibrate(pattern) { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; navigator.vibrate(pattern); } + +/** + * Führt eine DELETE-Aktion aus und zeigt einen Undo-Toast. + * + * @param {Object} opts + * @param {() => Promise} opts.onDelete - Async-Funktion die DELETE ausführt + * @param {() => Promise} [opts.onUndo] - Async-Funktion die die Aktion rückgängig macht + * @param {string} opts.toastMessage - Text für den Toast + * @param {'success'|'danger'} [opts.toastType] - Toast-Typ, default 'success' + */ +export async function deleteWithUndo({ onDelete, onUndo, toastMessage, toastType = 'success' }) { + await onDelete(); + if (window.oikos?.showToast) { + window.oikos.showToast( + toastMessage, + toastType, + onUndo ? 4000 : 2000, + onUndo ?? null, + ); + } +} diff --git a/test-ux-utils.js b/test-ux-utils.js index 6c4fb87..f789ca9 100644 --- a/test-ux-utils.js +++ b/test-ux-utils.js @@ -6,12 +6,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; // Minimales Window/Navigator-Mock für Node -const { stagger, vibrate } = await (async () => { - // stagger braucht window.matchMedia - wir mocken es +const { stagger, vibrate, deleteWithUndo } = await (async () => { global.window = { matchMedia: () => ({ matches: false }), + oikos: { showToast: () => {} }, }; - // navigator ist in Node ein getter-only property - über defineProperty überschreiben + global.t = (k) => k; Object.defineProperty(global, 'navigator', { value: { vibrate: null }, writable: true, @@ -47,3 +47,29 @@ test('vibrate: ruft navigator.vibrate auf wenn vorhanden', () => { vibrate(15); assert.equal(called, 15); }); + +test('deleteWithUndo: ruft onDelete auf', async () => { + let deleteCalled = false; + global.window.oikos = { showToast: () => {} }; + await deleteWithUndo({ + onDelete: async () => { deleteCalled = true; }, + toastMessage: 'Gelöscht', + }); + assert.equal(deleteCalled, true); +}); + +test('deleteWithUndo: übergibt onUndo an showToast', async () => { + let undoCalled = false; + let capturedUndo = null; + global.window.oikos = { + showToast: (_msg, _type, _duration, undoFn) => { capturedUndo = undoFn; }, + }; + await deleteWithUndo({ + onDelete: async () => {}, + onUndo: async () => { undoCalled = true; }, + toastMessage: 'Gelöscht', + }); + assert.ok(capturedUndo, 'showToast muss eine Undo-Funktion erhalten haben'); + await capturedUndo(); + assert.equal(undoCalled, true); +});