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);
|
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', () => {
|
it('nennt den Kartennamen als Text, nie als HTML', () => {
|
||||||
void open({ cardName: '<img src=x onerror=alert(1)>' });
|
void open({ cardName: '<img src=x onerror=alert(1)>' });
|
||||||
expect(dialogEl()!.querySelector('img')).toBeNull();
|
expect(dialogEl()!.querySelector('img')).toBeNull();
|
||||||
@@ -221,6 +235,39 @@ describe('Veröffentlichen-Dialog: Senden', () => {
|
|||||||
await outcome;
|
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 () => {
|
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.'));
|
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
|
||||||
const outcome = open();
|
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 form = el('form', { class: 'report-form', novalidate: true }, intro, fields, errorEl, statusEl, actions);
|
||||||
const dialog = el(
|
const dialog = el(
|
||||||
'dialog',
|
'dialog',
|
||||||
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-title' },
|
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-heading' },
|
||||||
el('div', { class: 'report-card' }, el('h2', { id: 'publish-title', class: 'report-title' }, 'Karte veröffentlichen'), form),
|
// 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 = () => {
|
const finish = () => {
|
||||||
@@ -165,6 +166,17 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
|||||||
resolve(outcome);
|
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 {
|
function setBusy(value: boolean): void {
|
||||||
busy = value;
|
busy = value;
|
||||||
form.setAttribute('aria-busy', String(value));
|
form.setAttribute('aria-busy', String(value));
|
||||||
@@ -214,11 +226,13 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
|||||||
if (closed) return;
|
if (closed) return;
|
||||||
if (isAlreadyPublished(err)) {
|
if (isAlreadyPublished(err)) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
reopen();
|
||||||
showAlready();
|
showAlready();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
errorEl.textContent = publishFailureText(err);
|
errorEl.textContent = publishFailureText(err);
|
||||||
|
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
@@ -236,7 +250,14 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
|||||||
dialog.addEventListener('cancel', (event) => {
|
dialog.addEventListener('cancel', (event) => {
|
||||||
if (busy) event.preventDefault();
|
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,
|
// 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.
|
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
|
||||||
let pressedOnBackdrop = false;
|
let pressedOnBackdrop = false;
|
||||||
|
|||||||
@@ -768,16 +768,77 @@ describe('Meine Karten: Veröffentlichung (Status an der Kachel)', () => {
|
|||||||
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
|
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Löschen einer veröffentlichten Karte weist darauf hin, dass die Veröffentlichung bleibt', async () => {
|
const GENERIC_HINT =
|
||||||
await mountWith(pubMap(published('g1', 'a')));
|
'Falls diese Karte veröffentlicht ist, bleibt die Veröffentlichung bestehen – zurückziehen kannst du sie auf ihrer Community-Seite.';
|
||||||
button(tileFor('a'), 'Löschen').click();
|
const openDelete = async (id: string) => {
|
||||||
|
button(tileFor(id), 'Löschen').click();
|
||||||
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
|
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('bleibt in der Community-Galerie');
|
||||||
|
expect(dialog()!.textContent).toContain('Community-Seite');
|
||||||
|
expect(dialog()!.textContent).not.toContain('Falls diese Karte');
|
||||||
button(dialog()!, 'Abbrechen').click();
|
button(dialog()!, 'Abbrechen').click();
|
||||||
await vi.waitFor(() => expect(dialog()).toBeNull());
|
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));
|
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('');
|
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 () => {
|
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.'));
|
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
|
||||||
await start();
|
await start();
|
||||||
@@ -950,7 +1036,7 @@ describe('Meine Karten: Veröffentlichen', () => {
|
|||||||
sendForm();
|
sendForm();
|
||||||
await vi.waitFor(() => expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.'));
|
await vi.waitFor(() => expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.'));
|
||||||
expect(badge('a')).toBe('');
|
expect(badge('a')).toBe('');
|
||||||
expect(badge('b')).toBe('Veröffentlicht');
|
expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+53
-24
@@ -27,7 +27,11 @@ const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.';
|
|||||||
const EMPTY = 'Noch keine Karten gespeichert.';
|
const EMPTY = 'Noch keine Karten gespeichert.';
|
||||||
const PREVIEW_PARALLEL = 4;
|
const PREVIEW_PARALLEL = 4;
|
||||||
const STATUS_FAILED = 'Der Veröffentlichungsstatus konnte nicht geladen werden.';
|
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. */
|
/** Was die Kachel über die Veröffentlichung wissen muss. */
|
||||||
interface Publication {
|
interface Publication {
|
||||||
@@ -50,7 +54,10 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
|
|||||||
let userId = '';
|
let userId = '';
|
||||||
let publications = new Map<string, Publication>();
|
let publications = new Map<string, Publication>();
|
||||||
let publicationsState: 'loading' | 'ready' | 'error' = 'loading';
|
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>();
|
const tileUpdaters = new Map<string, () => void>();
|
||||||
let statusNotice: HTMLElement | null = null;
|
let statusNotice: HTMLElement | null = null;
|
||||||
|
|
||||||
@@ -139,7 +146,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
|
|||||||
state = 'loggedOut';
|
state = 'loggedOut';
|
||||||
cards = [];
|
cards = [];
|
||||||
publications = new Map();
|
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();
|
tileUpdaters.clear();
|
||||||
statusNotice = null;
|
statusNotice = null;
|
||||||
queue.length = 0;
|
queue.length = 0;
|
||||||
@@ -219,29 +226,39 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
|
|||||||
updateStatusNotice();
|
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> {
|
async function fetchPublications(): Promise<boolean> {
|
||||||
const token = ++publicationsToken;
|
const token = ++fetchToken;
|
||||||
try {
|
for (;;) {
|
||||||
const found = await myPublications(userId);
|
const seen = changes;
|
||||||
if (disposed || token !== publicationsToken) return false;
|
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>();
|
const next = new Map<string, Publication>();
|
||||||
for (const [cardId, entry] of found) next.set(cardId, { id: entry.id, title: entry.title, hidden: entry.hidden });
|
for (const [cardId, entry] of found) next.set(cardId, { id: entry.id, title: entry.title, hidden: entry.hidden });
|
||||||
publications = next;
|
publications = next;
|
||||||
publicationsState = 'ready';
|
publicationsState = 'ready';
|
||||||
} catch {
|
|
||||||
if (disposed || token !== publicationsToken) return false;
|
|
||||||
if (publicationsState === 'loading') publicationsState = 'error';
|
|
||||||
updateAllTiles();
|
updateAllTiles();
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
updateAllTiles();
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Trägt eine Veröffentlichung (oder ihr Ende) ein und aktualisiert nur diese Kachel. */
|
/** Trägt eine Veröffentlichung (oder ihr Ende) ein und aktualisiert nur diese Kachel. */
|
||||||
function setPublication(cardId: string, entry: Publication | null): void {
|
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);
|
if (entry) publications.set(cardId, entry);
|
||||||
else publications.delete(cardId);
|
else publications.delete(cardId);
|
||||||
tileUpdaters.get(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).
|
* 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
|
* 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 {
|
try {
|
||||||
const { id } = await withLogin(() => publishSavedCard(card, form));
|
const { id } = await withLogin(() => publishSavedCard(card, form));
|
||||||
if (!disposed) setPublication(card.id, { id, title: form.title.trim(), hidden: false });
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
if (isDefinitiveFailure(err)) throw err;
|
if (isDefinitiveFailure(err)) throw err;
|
||||||
const current = (await fetchPublications()) ? publications.get(card.id) : undefined;
|
let found: Publication | undefined;
|
||||||
if (!current) throw err;
|
try {
|
||||||
return { id: current.id };
|
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);
|
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 {
|
function startPublish(): void {
|
||||||
void run(async () => {
|
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"]'));
|
}).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(
|
const confirmed = await confirmDialog(
|
||||||
{
|
{
|
||||||
title: `Karte „${card.name}“ löschen?`,
|
title: `Karte „${card.name}“ löschen?`,
|
||||||
text: publications.has(card.id)
|
text: `Die Karte und ihr Bild werden endgültig gelöscht.${deleteNote(card)}`,
|
||||||
? `Die Karte und ihr Bild werden endgültig gelöscht. ${PUBLISHED_NOTE}`
|
|
||||||
: 'Die Karte und ihr Bild werden endgültig gelöscht.',
|
|
||||||
confirmLabel: 'Löschen',
|
confirmLabel: 'Löschen',
|
||||||
},
|
},
|
||||||
disposal.signal,
|
disposal.signal,
|
||||||
|
|||||||
Reference in new issue
Block a user