diff --git a/web/src/community/publish-dialog.test.ts b/web/src/community/publish-dialog.test.ts index 2740250..53de4b8 100644 --- a/web/src/community/publish-dialog.test.ts +++ b/web/src/community/publish-dialog.test.ts @@ -85,6 +85,20 @@ describe('Veröffentlichen-Dialog: Felder', () => { expect(consent().closest('label')!.textContent).toBe(CONSENT_TEXT); }); + it('jedes Feld hat sein Label (label.control === Eingabe), alle IDs sind eindeutig, der Titel des Dialogs ist die Überschrift', () => { + void open(); + const labels = [...form().querySelectorAll('label')]; + const inputs = [...form().querySelectorAll('input')]; + expect(labels).toHaveLength(5); + expect(labels.map((l) => l.control)).toEqual(inputs); + for (const control of inputs) expect(control.labels).toHaveLength(1); + const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id); + expect(new Set(ids).size).toBe(ids.length); + const heading = dialogEl()!.querySelector('h2')!; + expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(heading.id); + expect(inputs.map((i) => i.id)).not.toContain(heading.id); + }); + it('nennt den Kartennamen als Text, nie als HTML', () => { void open({ cardName: '' }); expect(dialogEl()!.querySelector('img')).toBeNull(); @@ -221,6 +235,39 @@ describe('Veröffentlichen-Dialog: Senden', () => { await outcome; }); + it('schließt der Browser den Dialog mitten im Senden (close-Ereignis), geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => { + let finish!: (r: { id: string }) => void; + submit.mockReturnValue(new Promise((r) => (finish = r))); + let resolved: PublishOutcome | null = null; + const outcome = open().then((o) => (resolved = o)); + fill(); + send(); + dialogEl()!.close(); // wie ein Browser, der Esc trotz verhindertem `cancel` auswertet + await settle(); + expect(dialogEl()?.open).toBe(true); + dialogEl()!.dispatchEvent(new Event('close')); + await settle(); + expect(resolved).toBeNull(); + expect(dialogEl()?.open).toBe(true); + finish({ id: 'g1' }); + await outcome; + expect(resolved).toBe('published'); + expect(dialogEl()).toBeNull(); + }); + + it('wurde der Dialog mitten im Senden geschlossen und das Senden scheitert, ist der Fehler sichtbar (Dialog wieder offen)', async () => { + let fail!: (e: Error) => void; + submit.mockReturnValue(new Promise((_, r) => (fail = r))); + void open(); + fill(); + send(); + dialogEl()!.close(); + fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); + await settle(); + expect(dialogEl()?.open).toBe(true); + expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'); + }); + it('Fehler des Dienstes: Servertext im Dialog, der Dialog bleibt offen, ein neuer Versuch ist möglich', async () => { submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); const outcome = open(); diff --git a/web/src/community/publish-dialog.ts b/web/src/community/publish-dialog.ts index 881397a..90574b3 100644 --- a/web/src/community/publish-dialog.ts +++ b/web/src/community/publish-dialog.ts @@ -151,8 +151,9 @@ export function publishDialog(opts: PublishDialogOptions): Promise`), sonst zeigt sein Label auf die Überschrift. + el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', class: 'report-title' }, 'Karte veröffentlichen'), form), ); const finish = () => { @@ -165,6 +166,17 @@ export function publishDialog(opts: PublishDialogOptions): Promise { if (busy) event.preventDefault(); }); - dialog.addEventListener('close', finish); + // Schließt der Browser den Dialog trotzdem mitten im Senden, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis. + dialog.addEventListener('close', () => { + if (busy && !closed) { + reopen(); + return; + } + finish(); + }); // Hintergrund = Klick auf das selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein, // sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog. let pressedOnBackdrop = false; diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index b292971..8007c2d 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -768,16 +768,77 @@ describe('Meine Karten: Veröffentlichung (Status an der Kachel)', () => { expect(mocks.myPublications).toHaveBeenCalledTimes(1); }); - it('Löschen einer veröffentlichten Karte weist darauf hin, dass die Veröffentlichung bleibt', async () => { - await mountWith(pubMap(published('g1', 'a'))); - button(tileFor('a'), 'Löschen').click(); + const GENERIC_HINT = + 'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.'; + const openDelete = async (id: string) => { + button(tileFor(id), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); + }; + + it('Löschen einer veröffentlichten Karte (Status bekannt) nennt, dass die Veröffentlichung bleibt und wo man sie zurückzieht', async () => { + await mountWith(pubMap(published('g1', 'a'))); + await openDelete('a'); expect(dialog()!.textContent).toContain('bleibt in der Community-Galerie'); + expect(dialog()!.textContent).toContain('Community-Seite'); + expect(dialog()!.textContent).not.toContain('Falls diese Karte'); button(dialog()!, 'Abbrechen').click(); await vi.waitFor(() => expect(dialog()).toBeNull()); - button(tileFor('b'), 'Löschen').click(); + // Bekannt unveröffentlicht: kein Hinweis. + await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false)); + await openDelete('b'); + expect(dialog()!.textContent).not.toContain('Community'); + }); + + it('Löschen, solange der Status noch lädt: allgemeiner Hinweis', async () => { + mocks.list.mockResolvedValue([card('a', 'Alpha')]); + mocks.myPublications.mockReturnValue(new Promise(() => {})); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(1)); + await openDelete('a'); + expect(dialog()!.textContent).toContain(GENERIC_HINT); + }); + + it('Löschen, wenn der Status nicht geladen werden konnte: allgemeiner Hinweis', async () => { + mocks.list.mockResolvedValue([card('a', 'Alpha')]); + mocks.myPublications.mockRejectedValue(new TypeError('offline')); + mount(); + await vi.waitFor(() => expect(text()).toContain('konnte nicht geladen werden')); + await openDelete('a'); + expect(dialog()!.textContent).toContain(GENERIC_HINT); + }); + + it('Status-Abfrage, die ein gleichzeitiges Zurückziehen überholt, lässt den Status nie in „lädt“ hängen', async () => { + mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); + mocks.myPublications.mockRejectedValueOnce(new TypeError('offline')); + mount(); + await vi.waitFor(() => expect(text()).toContain('konnte nicht geladen werden')); + await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); + // Im Fehlerzustand lässt sich veröffentlichen; danach gibt es „Zurückziehen“. + mocks.publishSavedCard.mockResolvedValue({ id: 'g9' }); + await openPublish('a'); + fillDialog(); + sendForm(); + await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); + // Zurückziehen läuft (Anfrage unterwegs) ... + let finishWithdraw!: () => void; + mocks.withdrawCard.mockReturnValue(new Promise((r) => (finishWithdraw = r))); + await vi.waitFor(() => expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false)); + button(tileFor('a'), 'Zurückziehen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); - expect(dialog()!.textContent).not.toContain('Community-Galerie'); + button(dialog()!, 'Zurückziehen').click(); + await vi.waitFor(() => expect(mocks.withdrawCard).toHaveBeenCalledWith('g9')); + // ... währenddessen „Nochmal versuchen“ (die Abfrage hängt noch) ... + let finishFetch!: (m: Map) => void; + mocks.myPublications.mockReturnValueOnce(new Promise((r) => (finishFetch = r))); + button(root, 'Nochmal versuchen').click(); + expect(labels(tileFor('b'))).not.toContain('Veröffentlichen'); // Status gilt wieder als unbekannt + // ... und das Zurückziehen wird fertig, bevor die Abfrage antwortet. + finishWithdraw(); + await vi.waitFor(() => expect(badge('a')).toBe('')); + finishFetch(new Map()); + await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Veröffentlichen')); + expect(labels(tileFor('a'))).toContain('Veröffentlichen'); + expect(text()).not.toContain('konnte nicht geladen werden'); }); }); @@ -941,6 +1002,31 @@ describe('Meine Karten: Veröffentlichen', () => { expect(badge('a')).toBe(''); }); + it('die Nachprüfung geht nicht verloren, wenn währenddessen eine andere Kachel ihren Status ändert', async () => { + mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); + mocks.withdrawCard.mockResolvedValue(undefined); + mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); + mocks.myPublications.mockResolvedValue(new Map([['b', published('g2', 'b')]])); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(2)); + await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Zurückziehen')); + await openPublish('a'); + fillDialog(); + let finishCheck!: (m: Map) => void; + mocks.myPublications.mockReturnValueOnce(new Promise((r) => (finishCheck = r))); + sendForm(); + await vi.waitFor(() => expect(mocks.myPublications).toHaveBeenCalledTimes(2)); + // Während die Nachprüfung läuft, zieht der Nutzer in einer anderen Kachel eine Karte zurück. + button(tileFor('b'), 'Zurückziehen').click(); + await vi.waitFor(() => expect(dialog()?.open).toBe(true)); + button(dialog()!, 'Zurückziehen').click(); + await vi.waitFor(() => expect(labels(tileFor('b'))).toContain('Veröffentlichen')); + finishCheck(new Map([['a', published('g1', 'a')]])); + await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); + expect(publishDialogEl()).toBeNull(); + expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); + }); + it('die Nachprüfung findet nur die Veröffentlichung dieser Karte (eine andere zählt nicht)', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await start(); @@ -950,7 +1036,7 @@ describe('Meine Karten: Veröffentlichen', () => { sendForm(); await vi.waitFor(() => expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.')); expect(badge('a')).toBe(''); - expect(badge('b')).toBe('Veröffentlicht'); + expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.'); }); }); diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts index a0264fb..848f8d9 100644 --- a/web/src/pages/cards-page.ts +++ b/web/src/pages/cards-page.ts @@ -27,7 +27,11 @@ const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.'; const EMPTY = 'Noch keine Karten gespeichert.'; const PREVIEW_PARALLEL = 4; const STATUS_FAILED = 'Der Veröffentlichungsstatus konnte nicht geladen werden.'; -const PUBLISHED_NOTE = 'Die veröffentlichte Karte bleibt in der Community-Galerie, bis du sie zurückziehst.'; +// Löschen einer privaten Karte lässt ihre Veröffentlichung bestehen; zurückgezogen wird dann auf der Community-Seite. +const PUBLISHED_NOTE = + 'Die veröffentlichte Karte bleibt in der Community-Galerie, bis du sie zurückziehst – das geht dann nur noch auf ihrer Community-Seite.'; +const MAYBE_PUBLISHED_NOTE = + 'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.'; /** Was die Kachel über die Veröffentlichung wissen muss. */ interface Publication { @@ -50,7 +54,10 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi let userId = ''; let publications = new Map(); let publicationsState: 'loading' | 'ready' | 'error' = 'loading'; - let publicationsToken = 0; + /** Zählt Abfragen: nur die neueste darf den Stand setzen (Abmelden zählt auch, die alte Abfrage gehört dann dem alten Nutzer). */ + let fetchToken = 0; + /** Zählt lokale Änderungen (`setPublication`): eine Abfrage, die währenddessen lief, kann älter sein und wird wiederholt. */ + let changes = 0; const tileUpdaters = new Map void>(); let statusNotice: HTMLElement | null = null; @@ -139,7 +146,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi state = 'loggedOut'; cards = []; publications = new Map(); - publicationsToken += 1; // eine noch laufende Abfrage gehört dem alten Nutzer + fetchToken += 1; // eine noch laufende Abfrage gehört dem alten Nutzer tileUpdaters.clear(); statusNotice = null; queue.length = 0; @@ -219,29 +226,39 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi updateStatusNotice(); } - /** Holt die eigenen Veröffentlichungen neu. Wahr, wenn der Stand jetzt stimmt; bei Fehler bleibt der bisherige Stand. */ + /** + * Holt die eigenen Veröffentlichungen neu. Wahr, wenn der Stand jetzt stimmt; bei Fehler bleibt der bisherige Stand + * (war er noch unbekannt, gilt er als „Fehler“). Ändert sich währenddessen lokal etwas (Veröffentlichen/Zurückziehen), + * kann die Antwort älter sein als diese Änderung und wird nicht übernommen, sondern neu angefragt. Verdrängt eine + * neuere Abfrage diese, setzt sie den Stand; der Status bleibt so nie in „lädt“ hängen. + */ async function fetchPublications(): Promise { - const token = ++publicationsToken; - try { - const found = await myPublications(userId); - if (disposed || token !== publicationsToken) return false; + const token = ++fetchToken; + for (;;) { + const seen = changes; + let found: Awaited>; + try { + found = await myPublications(userId); + } catch { + if (disposed || token !== fetchToken) return false; + if (publicationsState === 'loading') publicationsState = 'error'; + updateAllTiles(); + return false; + } + if (disposed || token !== fetchToken) return false; + if (changes !== seen) continue; const next = new Map(); for (const [cardId, entry] of found) next.set(cardId, { id: entry.id, title: entry.title, hidden: entry.hidden }); publications = next; publicationsState = 'ready'; - } catch { - if (disposed || token !== publicationsToken) return false; - if (publicationsState === 'loading') publicationsState = 'error'; updateAllTiles(); - return false; + return true; } - updateAllTiles(); - return true; } /** Trägt eine Veröffentlichung (oder ihr Ende) ein und aktualisiert nur diese Kachel. */ function setPublication(cardId: string, entry: Publication | null): void { - publicationsToken += 1; // eine noch laufende Abfrage kann älter sein als diese Änderung + changes += 1; if (entry) publications.set(cardId, entry); else publications.delete(cardId); tileUpdaters.get(cardId)?.(); @@ -250,9 +267,10 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi /** * Veröffentlicht die Karte. Ist sie schon veröffentlicht (409), wird der Status neu geholt (der Dialog zeigt den Text). * Bleibt der Ausgang offen (503, 429, 408, 5xx: die Veröffentlichung kann trotzdem angelegt worden sein), wird vor - * der Fehlermeldung nachgesehen und bei Erfolg der Erfolgszustand gezeigt. + * der Fehlermeldung nachgesehen und bei Erfolg der Erfolgszustand gezeigt. Die Nachsicht ist eine eigene Abfrage, + * unabhängig vom Status-Laden der Seite, damit weder ein Neuladen noch eine andere Kachel sie verwirft. */ - async function publishCard(card: SavedCard, form: PublishForm): Promise<{ id: string }> { + async function publishWithCheck(card: SavedCard, form: PublishForm): Promise<{ id: string }> { try { const { id } = await withLogin(() => publishSavedCard(card, form)); if (!disposed) setPublication(card.id, { id, title: form.title.trim(), hidden: false }); @@ -263,9 +281,16 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi throw err; } if (isDefinitiveFailure(err)) throw err; - const current = (await fetchPublications()) ? publications.get(card.id) : undefined; - if (!current) throw err; - return { id: current.id }; + let found: Publication | undefined; + try { + const entry = (await myPublications(userId)).get(card.id); + if (entry) found = { id: entry.id, title: entry.title, hidden: entry.hidden }; + } catch { + // Auch die Nachsicht scheitert: es bleibt bei der ursprünglichen Meldung. + } + if (!found) throw err; + if (!disposed) setPublication(card.id, found); + return { id: found.id }; } } @@ -360,9 +385,15 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi publishSlot.replaceChildren(...controls); } + /** Zusatz im Löschen-Dialog: die Veröffentlichung bleibt. Ist der Status unbekannt, ein allgemeiner Hinweis. */ + function deleteNote(c: SavedCard): string { + if (publicationsState !== 'ready') return ` ${MAYBE_PUBLISHED_NOTE}`; + return publications.has(c.id) ? ` ${PUBLISHED_NOTE}` : ''; + } + function startPublish(): void { void run(async () => { - await publishDialog({ cardName: card.name, signal: disposal.signal, submit: (form) => publishCard(card, form) }); + await publishDialog({ cardName: card.name, signal: disposal.signal, submit: (form) => publishWithCheck(card, form) }); }).then(() => focusControl('[data-action="view"], [data-action="withdraw"], [data-action="publish"]')); } @@ -415,9 +446,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi const confirmed = await confirmDialog( { title: `Karte „${card.name}“ löschen?`, - text: publications.has(card.id) - ? `Die Karte und ihr Bild werden endgültig gelöscht. ${PUBLISHED_NOTE}` - : 'Die Karte und ihr Bild werden endgültig gelöscht.', + text: `Die Karte und ihr Bild werden endgültig gelöscht.${deleteNote(card)}`, confirmLabel: 'Löschen', }, disposal.signal,