diff --git a/web/src/logos/logo-info.test.ts b/web/src/logos/logo-info.test.ts index 1bbf458..e8d26fa 100644 --- a/web/src/logos/logo-info.test.ts +++ b/web/src/logos/logo-info.test.ts @@ -673,6 +673,37 @@ describe('Admin', () => { expect(fx.listLogoReports).toHaveBeenCalledTimes(1); // nichts mehr zu holen }); + it('nach „Einblenden“ (0 Meldungen) sind die alten Gründe verworfen: kommen später wieder Meldungen, werden sie frisch geholt, auch bei gleicher Zahl', async () => { + fx.listLogoReports.mockResolvedValueOnce([report('spam'), report('sonstiges')]); + await mount(logo({ hidden: true, reportCount: 2 })); + expect(reasons()).toEqual(['Spam', 'Sonstiges']); + button('Einblenden').click(); + await flush(); + expect(reportCount()).toBe('0 Meldungen'); + expect(reasons()).toEqual([]); + expect(fx.listLogoReports).toHaveBeenCalledTimes(1); + + // Zwei neue Meldungen, wieder genau zwei: gleicher Schlüssel wie vorher, aber andere Gründe. + fx.listLogoReports.mockResolvedValueOnce([report('urheberrecht', 'neu1'), report('unangemessen', 'neu2')]); + row = logo({ reportCount: 2 }); + info.update(); + await flush(); + expect(fx.listLogoReports).toHaveBeenCalledTimes(2); + expect(reasons()).toEqual(['Urheberrecht/Marke', 'Unangemessen']); + }); + + it('eine späte Antwort der alten Gründe, die nach dem Einblenden eintrifft, wird verworfen', async () => { + const slow = deferred(); + fx.listLogoReports.mockReturnValueOnce(slow.promise); + await mount(logo({ hidden: true, reportCount: 2 })); + button('Einblenden').click(); + await flush(); + slow.resolve([report('spam'), report('sonstiges')]); + await flush(); + expect(reportCount()).toBe('0 Meldungen'); + expect(reasons()).toEqual([]); + }); + it('Fehler beim Ein- oder Ausblenden: Text in der Meldezeile, die Zeile bleibt, kein forgetLogo', async () => { fx.setLogoVisibility.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); await mount(logo()); @@ -730,6 +761,191 @@ describe('Admin', () => { }); }); +describe('Aktionen: Zustand während sie laufen', () => { + const busy = () => info.el.getAttribute('aria-busy'); + const disabled = () => ['Melden', 'Löschen', 'Ausblenden'].filter((label) => button(label)?.disabled); + + beforeEach(() => { + admin = true; // alle drei Knöpfe: Melden, Löschen, Ausblenden + }); + + it('im Ruhezustand: nicht beschäftigt, kein Knopf gesperrt', async () => { + await mount(); + expect(busy()).toBe('false'); + expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); + expect(disabled()).toEqual([]); + }); + + it('läuft eine Anfrage (Löschen): aria-busy="true" an der Infozeile, die anderen Knöpfe sind gesperrt, der gedrückte nicht und behält den Fokus', async () => { + const request = deferred(); + fx.deleteLogo.mockReturnValue(request.promise); + await mount(); + button('Löschen').focus(); + button('Löschen').click(); + await flush(); // die Rückfrage ist beantwortet, die Anfrage läuft + expect(fx.deleteLogo).toHaveBeenCalledTimes(1); + expect(busy()).toBe('true'); + expect(disabled()).toEqual(['Melden', 'Ausblenden']); + expect(button('Löschen').disabled).toBe(false); + expect(document.activeElement).toBe(button('Löschen')); + request.resolve(); + await flush(); + expect(busy()).toBe('false'); + expect(disabled()).toEqual([]); + }); + + it('schon die Rückfrage vor dem Löschen zählt: beschäftigt, solange sie offen ist', async () => { + const answer = deferred(); + fx.confirm.mockReturnValue(answer.promise); + await mount(); + button('Löschen').click(); + expect(busy()).toBe('true'); + expect(disabled()).toEqual(['Melden', 'Ausblenden']); + answer.resolve(false); // abgelehnt + await flush(); + expect(busy()).toBe('false'); + expect(disabled()).toEqual([]); + }); + + it('der Melden-Dialog zählt ebenso; der gedrückte Knopf ist „Melden“', async () => { + const open = deferred<'reported'>(); + fx.reportDialog.mockReturnValue(open.promise); + await mount(); + button('Melden').click(); + expect(busy()).toBe('true'); + expect(disabled()).toEqual(['Löschen', 'Ausblenden']); + open.resolve('reported'); + await flush(); + expect(busy()).toBe('false'); + expect(disabled()).toEqual([]); + }); + + it('Ausblenden: der gedrückte Knopf ist „Ausblenden“; nach dem Umschalten ist alles wieder frei', async () => { + const request = deferred(); + fx.setLogoVisibility.mockReturnValue(request.promise); + await mount(); + button('Ausblenden').click(); + expect(busy()).toBe('true'); + expect(disabled()).toEqual(['Melden', 'Löschen']); + request.resolve(); + await flush(); + expect(busy()).toBe('false'); + expect(disabled()).toEqual([]); + expect(buttons()).toEqual(['Löschen', 'Einblenden']); + }); + + it('scheitert die Aktion, ist alles wieder frei, und der Fehler steht da', async () => { + fx.deleteLogo.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); + await mount(); + button('Löschen').click(); + await flush(); + expect(message()).toBe('Dazu hast du keine Berechtigung.'); + expect(busy()).toBe('false'); + expect(disabled()).toEqual([]); + }); + + it('nach dem Verlassen der Seite (abort) ist die Infozeile auch wieder frei', async () => { + const request = deferred(); + fx.deleteLogo.mockReturnValue(request.promise); + await mount(); + button('Löschen').click(); + await flush(); + abort.abort(); + request.resolve(); + await flush(); + expect(busy()).toBe('false'); + expect(disabled()).toEqual([]); + }); + + it('ein Klick auf einen gesperrten oder auf denselben Knopf noch einmal bleibt ohne Wirkung', async () => { + const request = deferred(); + fx.deleteLogo.mockReturnValue(request.promise); + await mount(); + button('Löschen').click(); + await flush(); + button('Melden').click(); // gesperrt: ein Klick auf einen gesperrten Knopf erreicht nichts + button('Ausblenden').click(); + button('Löschen').click(); // läuft schon + await flush(); + expect(fx.reportDialog).not.toHaveBeenCalled(); + expect(fx.setLogoVisibility).not.toHaveBeenCalled(); + expect(fx.confirm).toHaveBeenCalledTimes(1); + request.resolve(); + await flush(); + }); +}); + +describe('Aktionen: Ergebnis gehört zur Auswahl, für die sie gestartet wurden', () => { + it('ein Fehler, der erst eintrifft, nachdem ein anderes Logo gewählt wurde, steht nicht unter dem neuen Logo', async () => { + user = 'u9'; + const request = deferred(); + fx.deleteLogo.mockReturnValue(request.promise); + await mount(logo({ id: 'a', name: 'Logo A' })); + button('Löschen').click(); + await flush(); + choose(logo({ id: 'b', name: 'Logo B' })); // der Nutzer wählt inzwischen B + request.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); + await flush(); + expect(message()).toBe(''); + expect(line()).toBe('Logo B · von Kenso · ♥ 12'); + }); + + it('wählt der Nutzer „Kein Logo“ und danach wieder dasselbe Logo, ist es für die Anzeige eine neue Auswahl: kein später Fehler dort', async () => { + user = 'u9'; + const request = deferred(); + fx.deleteLogo.mockReturnValue(request.promise); + await mount(logo({ id: 'a' })); + button('Löschen').click(); + await flush(); + choose(null); + request.reject(new TypeError('offline')); + await flush(); + expect(message()).toBe(''); + }); + + it('bleibt die Auswahl dieselbe, steht der Fehler da (auch wenn sich die Zeile zwischendurch aktualisiert hat)', async () => { + user = 'u9'; + const request = deferred(); + fx.deleteLogo.mockReturnValue(request.promise); + await mount(logo({ id: 'a' })); + button('Löschen').click(); + await flush(); + row = logo({ id: 'a', likeCount: 13 }); + info.update(); + request.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); + await flush(); + expect(message()).toBe('Dazu hast du keine Berechtigung.'); + }); + + it('ein Fehler beim Ein- oder Ausblenden folgt derselben Regel', async () => { + admin = true; + const request = deferred(); + fx.setLogoVisibility.mockReturnValue(request.promise); + await mount(logo({ id: 'a' })); + button('Ausblenden').click(); + await flush(); + choose(logo({ id: 'b', name: 'Logo B' })); + request.reject(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); + await flush(); + expect(message()).toBe(''); + }); + + it('ein gelungenes Ein- oder Ausblenden ändert die Zeile des Logos, für das es gestartet wurde, auch wenn inzwischen ein anderes gewählt ist', async () => { + admin = true; + const request = deferred(); + fx.setLogoVisibility.mockReturnValue(request.promise); + await mount(logo({ id: 'a' })); + button('Ausblenden').click(); + await flush(); + choose(logo({ id: 'b', name: 'Logo B' })); + request.resolve(); + await flush(); + expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: true }); + expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a'); + expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); // B ist unberührt + }); +}); + describe('Meldezeile', () => { it('zeigt einen Text in einer Live-Region (role="alert") und löscht ihn mit einem leeren Text', async () => { await mount(); diff --git a/web/src/logos/logo-info.ts b/web/src/logos/logo-info.ts index 86001d9..1b1f448 100644 --- a/web/src/logos/logo-info.ts +++ b/web/src/logos/logo-info.ts @@ -4,7 +4,8 @@ // der Statusbereich des Pickers den Ladefehlern der Liste vorbehalten. // Die Zeilen und die Auswahl gehören dem Picker (`host`); die Infozeile liest sie und ändert sie nur über `patchRow` und `removeLogo`. // Der Aufbau steht einmal da und `update()` frischt nur Texte und Sichtbarkeit auf: ein Knopf, der gerade den Fokus hat, behält ihn. -// Alle Texte gehen als Text in den DOM, nie als HTML. +// Läuft eine Aktion, ist die Infozeile `aria-busy` und die übrigen Knöpfe sind gesperrt (der gedrückte nicht, er behält den Fokus); +// ein Fehler erscheint nur, wenn noch dasselbe Logo gewählt ist wie beim Start. Alle Texte gehen als Text in den DOM, nie als HTML. import '../account/account.css'; import './logos.css'; import { errorMessage } from '../account/errors'; @@ -78,7 +79,7 @@ interface Reasons { /** `signal` schließt offene Dialoge und beendet laufende Aktionen (Seite verlassen). */ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: AbortSignal): LogoInfo { - /** Eine Aktion (Rückfrage, Dialog oder Anfrage) läuft: weitere Klicks bleiben ohne Wirkung. */ + /** Eine Aktion (Rückfrage, Dialog oder Anfrage) läuft: weitere Klicks bleiben ohne Wirkung (die Knöpfe sind dann auch gesperrt). */ let busy = false; /** Für diese Auswahl wurde zuletzt gezeichnet: ein Wechsel räumt Meldung und Gründe ab. */ let shownId: string | null = null; @@ -103,7 +104,8 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A // Immer da (leer nehmen sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt. const goneEl = el('p', { class: 'auth-msg logo-info-gone', role: 'status' }); const messageEl = el('p', { class: 'auth-msg auth-msg-error logo-info-msg', role: 'alert' }); - const root = el('div', { class: 'logo-info' }, cardEl, goneEl, messageEl); + const root = el('div', { class: 'logo-info', 'aria-busy': 'false' }, cardEl, goneEl, messageEl); + const actionButtons = [reportButton, deleteButton, visibilityButton]; // ---- Anzeige -------------------------------------------------------------------------------------------------------------- @@ -136,7 +138,10 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A function renderReports(logo: CommunityLogo): void { reportsEl.textContent = reportsLabel(logo.reportCount); const key = `${logo.id}:${logo.reportCount}`; - if (logo.reportCount > 0 && reasons?.key !== key) { + if (logo.reportCount === 0) { + // Keine Meldungen (auch nach „Einblenden“, das sie löscht): nichts, was später bei gleicher Zahl wiederverwendet werden dürfte. + reasons = null; + } else if (reasons?.key !== key) { reasons = { key, status: 'loading', list: [] }; void loadReasons(logo.id, key); } @@ -174,17 +179,28 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A // ---- Aktionen ------------------------------------------------------------------------------------------------------------- - /** Führt eine Aktion aus: immer nur eine, die Meldezeile wird vorher geleert, ein Fehler erscheint dort als Text. */ - async function run(action: () => Promise): Promise { + /** Setzt, ob eine Aktion läuft: `aria-busy` an der Infozeile und die anderen Aktionsknöpfe gesperrt. Der gedrückte bleibt bedienbar und behält so den Fokus. */ + function setBusy(value: boolean, pressed?: HTMLButtonElement): void { + busy = value; + root.setAttribute('aria-busy', String(value)); + for (const button of actionButtons) button.disabled = value && button !== pressed; + } + + /** + * Führt eine Aktion aus: immer nur eine, die Meldezeile wird vorher geleert, ein Fehler erscheint dort als Text. Er gehört zum Logo, + * für das die Aktion gestartet wurde: hat der Nutzer inzwischen ein anderes gewählt, steht er nicht unter diesem. + */ + async function run(action: () => Promise, pressed: HTMLButtonElement): Promise { if (busy) return; - busy = true; + const startedFor = host.selectedId(); + setBusy(true, pressed); setMessage(''); try { await action(); } catch (err) { - if (!signal.aborted) setMessage(failureText(err)); + if (!signal.aborted && host.selectedId() === startedFor) setMessage(failureText(err)); } finally { - busy = false; + setBusy(false); } } @@ -234,9 +250,9 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A render(); } - reportButton.addEventListener('click', () => void run(report)); - deleteButton.addEventListener('click', () => void run(remove)); - visibilityButton.addEventListener('click', () => void run(toggleVisibility)); + reportButton.addEventListener('click', () => void run(report, reportButton)); + deleteButton.addEventListener('click', () => void run(remove, deleteButton)); + visibilityButton.addEventListener('click', () => void run(toggleVisibility, visibilityButton)); render(); return { el: root, update: render, setMessage };