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:
1 parent
f6b9ee61f4
commit
87b71ec71f
4 files changed
+216
-33
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user