fix(community): Label-ID im Veröffentlichen-Dialog, Status nie in „lädt“, Nachprüfung robust

Die Überschrift des Dialogs hatte dieselbe ID wie das Titelfeld (publish-title), das Label des
Pflichtfelds zeigte damit auf die Überschrift; jetzt publish-heading. Schließt der Browser den Dialog
mitten im Senden, wird er wieder geöffnet statt als abgebrochen aufgelöst. Die Status-Abfrage der Seite
wird bei einer lokalen Änderung während der Anfrage wiederholt (nie in „lädt“ hängen), die Nachprüfung nach
offenem Ausgang ist eine eigene Abfrage. Der Löschen-Dialog nennt bei unbekanntem Status einen allgemeinen
Hinweis und sagt, dass sich eine Veröffentlichung nur auf der Community-Seite zurückziehen lässt.
publishCard heißt lokal publishWithCheck.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 15:56:21 +00:00
1 parent f6b9ee61f4
commit 87b71ec71f
4 files changed
+216 -33

No files matched your search

+47
View File
@@ -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: '<img src=x onerror=alert(1)>' });
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();
+24 -3
View File
@@ -151,8 +151,9 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
const form = el('form', { class: 'report-form', novalidate: true }, intro, fields, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-title' },
el('div', { class: 'report-card' }, el('h2', { id: 'publish-title', class: 'report-title' }, 'Karte veröffentlichen'), form),
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-heading' },
// Eigene ID für die Überschrift: `publish-title` gehört dem Titelfeld (`publish-<name>`), 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<PublishOutcom
resolve(outcome);
};
/** Öffnet den Dialog wieder, falls ihn der Browser geschlossen hat (Esc trotz verhindertem `cancel`). */
function reopen(): boolean {
if (dialog.open) return true;
try {
dialog.showModal();
return true;
} catch {
return false;
}
}
function setBusy(value: boolean): void {
busy = value;
form.setAttribute('aria-busy', String(value));
@@ -214,11 +226,13 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
if (closed) return;
if (isAlreadyPublished(err)) {
setBusy(false);
reopen();
showAlready();
return;
}
setBusy(false);
errorEl.textContent = publishFailureText(err);
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
return;
}
if (closed) return;
@@ -236,7 +250,14 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
dialog.addEventListener('cancel', (event) => {
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 <dialog> 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;
+92 -6
View File
@@ -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<void>((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<string, GalleryCard>) => 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<string, GalleryCard>) => 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.');
});
});
+53 -24
View File
@@ -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<string, Publication>();
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<string, () => 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<boolean> {
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<ReturnType<typeof myPublications>>;
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<string, Publication>();
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,