feat(ux): zentrales deleteWithUndo + Undo-Toast in Birthdays
deleteWithUndo in ux.js: onDelete ausführen, Undo-Toast anzeigen. Birthdays migriert; Contacts/Notes/Meals haben bereits optimistische Undo-Logik.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.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 { t, formatDate } from '/i18n.js';
|
||||||
import { esc } from '/utils/html.js';
|
import { esc } from '/utils/html.js';
|
||||||
|
|
||||||
@@ -378,15 +378,26 @@ function openBirthdayModal({ mode, birthday = null }) {
|
|||||||
|
|
||||||
async function deleteBirthday(id, name) {
|
async function deleteBirthday(id, name) {
|
||||||
if (!await confirmModal(t('birthdays.deleteConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return;
|
if (!await confirmModal(t('birthdays.deleteConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||||
await api.delete(`/birthdays/${id}`);
|
const birthday = state.birthdays.find((b) => b.id === id);
|
||||||
state.birthdays = state.birthdays
|
state.birthdays = state.birthdays.filter((b) => b.id !== id).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
.filter((birthday) => birthday.id !== id)
|
state.upcoming = state.upcoming.filter((b) => b.id !== id);
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
state.upcoming = state.upcoming.filter((birthday) => birthday.id !== id);
|
|
||||||
renderUpcoming();
|
renderUpcoming();
|
||||||
renderSuggestions();
|
renderSuggestions();
|
||||||
renderList();
|
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) {
|
export async function render(container) {
|
||||||
|
|||||||
@@ -40,3 +40,24 @@ export function vibrate(pattern) {
|
|||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
navigator.vibrate(pattern);
|
navigator.vibrate(pattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt eine DELETE-Aktion aus und zeigt einen Undo-Toast.
|
||||||
|
*
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {() => Promise<void>} opts.onDelete - Async-Funktion die DELETE ausführt
|
||||||
|
* @param {() => Promise<void>} [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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+29
-3
@@ -6,12 +6,12 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
// Minimales Window/Navigator-Mock für Node
|
// Minimales Window/Navigator-Mock für Node
|
||||||
const { stagger, vibrate } = await (async () => {
|
const { stagger, vibrate, deleteWithUndo } = await (async () => {
|
||||||
// stagger braucht window.matchMedia - wir mocken es
|
|
||||||
global.window = {
|
global.window = {
|
||||||
matchMedia: () => ({ matches: false }),
|
matchMedia: () => ({ matches: false }),
|
||||||
|
oikos: { showToast: () => {} },
|
||||||
};
|
};
|
||||||
// navigator ist in Node ein getter-only property - über defineProperty überschreiben
|
global.t = (k) => k;
|
||||||
Object.defineProperty(global, 'navigator', {
|
Object.defineProperty(global, 'navigator', {
|
||||||
value: { vibrate: null },
|
value: { vibrate: null },
|
||||||
writable: true,
|
writable: true,
|
||||||
@@ -47,3 +47,29 @@ test('vibrate: ruft navigator.vibrate auf wenn vorhanden', () => {
|
|||||||
vibrate(15);
|
vibrate(15);
|
||||||
assert.equal(called, 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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user