diff --git a/public/termine-speichern.js b/public/termine-speichern.js index 57bd43b..4fdd0a7 100644 --- a/public/termine-speichern.js +++ b/public/termine-speichern.js @@ -1,6 +1,6 @@ /* Terminplaner im Kundenbereich: Speichern der Einstellungen – gemeinsam fuer die Reiter Leistungen, Team & Plaetze und Einstellungen. Jede Aenderung ist ein Speichern: PATCH /api/termine/einstellungen schickt die GANZE Einstellung mit der Version, auf der sie beruht - (server/routes/termine.js). Der Reiter gibt nur an, was sich aendert: speichere(aendere, { container, knopf }) holt eine Kopie des + (server/routes/termine.js). Der Reiter gibt nur an, was sich aendert: speichere(aendere, { container }) holt eine Kopie des aktuellen Standes (ctx.daten.einstellung), laesst aendere(entwurf) sie aendern und schickt sie ab. - Erfolg: die zurueckgegebene Einstellung und Version gelten (ctx.daten), die Hinweisleiste der Huelle wird neu gezeichnet und die Daten (Kalender-Links) werden neu geladen; Rueckgabe true. @@ -16,6 +16,9 @@ import { allgemeinerFehler, frage, h, knopfZeile, setzeFehler, zeigeFehler } from './termine-dom.js' +/** Wie viele Speichern gerade auf einem Tabpanel laufen (aria-busy bleibt, bis das letzte fertig ist). */ +const belegt = new WeakMap() + export const VERALTET_TEXT = 'Die Einstellungen wurden inzwischen geändert. Bitte neu laden.' const WORT = { leistungen: 'Leistung', abwesenheiten: 'Abwesenheit' } @@ -62,18 +65,23 @@ export function neuerSpeicher(ctx, wurzel, neuZeichnen) { } }) - // Ein Speichern zur Zeit: waehrenddessen aria-busy am Reiter und alle Knoepfe darin gesperrt (danach wieder frei, der Fokus kehrt zurueck) + // Ein Speichern zur Zeit: waehrenddessen aria-busy am Reiter und alle Knoepfe darin gesperrt (danach wieder frei, der Fokus kehrt zurueck). + // Das Tabpanel ist fuer alle Reiter dasselbe Element und wechselt der Nutzer waehrend des Speicherns den Reiter, ist wurzel beim Freigeben + // schon ausgehaengt: darum merkt sich die Sperre das Panel, das sie gesetzt hat, und aria-busy faellt erst, wenn kein Speichern mehr darauf laeuft. let laeuft = false function sperre(an, gesperrt) { - const tafel = wurzel.closest('[role="tabpanel"]') || wurzel if (an) { + const tafel = wurzel.closest('[role="tabpanel"]') || wurzel + belegt.set(tafel, (belegt.get(tafel) || 0) + 1) tafel.setAttribute('aria-busy', 'true') const knoepfe = [...wurzel.querySelectorAll('button:not(:disabled)')] const fokus = document.activeElement for (const knopf of knoepfe) knopf.disabled = true - return { knoepfe, fokus } + return { tafel, knoepfe, fokus } } - tafel.removeAttribute('aria-busy') + const rest = (belegt.get(gesperrt.tafel) || 1) - 1 + belegt.set(gesperrt.tafel, rest) + if (rest === 0) gesperrt.tafel.removeAttribute('aria-busy') for (const knopf of gesperrt.knoepfe) knopf.disabled = false // Der Fokus war auf einem gesperrten Knopf und ist weg: zurueck dorthin; hat der Reiter ihn inzwischen anders gesetzt (z. B. auf ein Feld mit Fehler), bleibt das so const jetzt = document.activeElement diff --git a/public/termine-tab-team.js b/public/termine-tab-team.js index efd01bf..437961a 100644 --- a/public/termine-tab-team.js +++ b/public/termine-tab-team.js @@ -188,22 +188,29 @@ function kalenderLink(t, r) { allgemeinerFehler(), zeile) // exklusiv wie ein Speichern: das Erzeugen aendert die Version, ein gleichzeitiges Speichern bekaeme sonst die alte - box.addEventListener('submit', () => t.sp.exklusiv(async () => { - try { - const antwort = await ctx.api.send('POST', `/ressourcen/${encodeURIComponent(r.id)}/feed`) - if (!ctx.aktuell() || !eingabe.isConnected) return - ctx.daten.version = antwort.version // das Erzeugen zaehlt die Version hoch: das naechste Speichern baut darauf auf - ctx.daten.feeds = { ...ctx.daten.feeds, [r.id]: antwort.url } - eingabe.value = antwort.url - frageBox.replaceChildren() - status.textContent = 'Der neue Link ist da. Der alte funktioniert nicht mehr.' - ctx.meldung(`Neuer Kalender-Link für „${r.name}“ erzeugt.`) - ausloeser.focus() - } catch (err) { - if (!ctx.aktuell() || !eingabe.isConnected) return - zeigeFehler(box, err) - } - })) + box.addEventListener('submit', async () => { + const erzeugt = await t.sp.exklusiv(async () => { + try { + const antwort = await ctx.api.send('POST', `/ressourcen/${encodeURIComponent(r.id)}/feed`) + if (!ctx.aktuell()) return false + ctx.daten.version = antwort.version // das Erzeugen zaehlt die Version hoch: das naechste Speichern baut darauf auf + ctx.daten.feeds = { ...ctx.daten.feeds, [r.id]: antwort.url } + if (!eingabe.isConnected) return true // der Reiter wurde inzwischen neu gezeichnet und zeigt schon den neuen Link + eingabe.value = antwort.url + frageBox.replaceChildren() + status.textContent = 'Der neue Link ist da. Der alte funktioniert nicht mehr.' + ctx.meldung(`Neuer Kalender-Link für „${r.name}“ erzeugt.`) + return true + } catch (err) { + if (ctx.aktuell() && eingabe.isConnected) zeigeFehler(box, err) + return false + } + }) + // Fokus erst nach dem Freigeben der Knoepfe (im gesperrten Zustand nimmt ein Knopf keinen Fokus an); ersetzt, den Knopf derselben Person suchen + if (!erzeugt || !t.sp.nochHier()) return + const knopf = ausloeser.isConnected ? ausloeser : t.wurzel.querySelector(`li[data-id="${r.id}"] [data-aktion="link-neu"]`) + knopf?.focus() + }) frageBox.replaceChildren(box) box.focus() } diff --git a/test/ui/termine-portal.ui.mjs b/test/ui/termine-portal.ui.mjs index 53022a9..c046a92 100644 --- a/test/ui/termine-portal.ui.mjs +++ b/test/ui/termine-portal.ui.mjs @@ -1590,6 +1590,55 @@ async function pruefungen() { eq([auswahl.von, auswahl.bis], [0, auswahl.laenge], 'der ganze Link ist markiert') }) + // ------------------------------------------------------------ Fokus nach „Neuen Link erzeugen“ + await check('echt_feed_link_fokus_nach_erzeugen', async (page) => { + await oeffneReiter(page, 'team') + await knopf(zeileMitId(page, R_ANNA), 'Bearbeiten: Anna').click() + const feed = zeileMitId(page, R_ANNA).locator('.tm-feed') + const link = feed.locator('.tm-link-feld') + const vorher = await link.inputValue() + await knopf(feed, 'Neuen Link erzeugen').click() + await knopf(page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' }), 'Neuen Link erzeugen').click() + await page.waitForFunction((alt) => document.querySelector('#tm-panel .tm-link-feld')?.value !== alt, vorher) + await page.waitForFunction(() => !document.querySelector('#tm-panel').hasAttribute('aria-busy')) + // der Fokus steht wieder auf dem Knopf, der die Rueckfrage geoeffnet hat (und der ist frei), nicht verloren auf der Seite + const aktiv = await page.evaluate(() => ({ aktion: document.activeElement?.dataset?.aktion, gesperrt: document.activeElement?.disabled, text: document.activeElement?.textContent })) + eq(aktiv, { aktion: 'link-neu', gesperrt: false, text: 'Neuen Link erzeugen' }, 'Fokus nach dem Erzeugen') + // ... und per Tastatur geht es weiter: Enter oeffnet die naechste Rueckfrage + await page.keyboard.press('Enter') + await page.getByRole('form', { name: 'Neuen Kalender-Link erzeugen?' }).waitFor() + }) + + // ------------------------------------------------------------ Reiter wechseln, waehrend ein Speichern laeuft: aria-busy faellt + await check('echt_busy_nach_reiterwechsel', async (page) => { + // die Antwort auf ein Speichern braucht 0,9 s + await page.route('**/api/termine/einstellungen', async (route) => { + if (route.request().method() === 'PATCH') await new Promise((fertig) => { setTimeout(fertig, 900) }) + await route.fallback() + }) + await oeffneReiter(page, 'leistungen') + const v0 = version() + await knopf(zeileMitId(page, S_HERREN), 'Bearbeiten: Herrenschnitt').click() + const form = formular(page, 'Leistung bearbeiten: Herrenschnitt') + await form.getByLabel('Dauer (Minuten)', { exact: true }).fill('35') + await knopf(form, 'Speichern').click() + await page.locator('#tm-panel[aria-busy="true"]').waitFor() + // mitten im Speichern zu Team & Plaetze wechseln: der neue Reiter hat sein eigenes, freies Gerueest + await page.locator('[data-reiter="team"]').click() + await page.locator('#tm-tab-team[aria-selected="true"]').waitFor() + await page.locator('#tm-panel li[data-id="r_anna0001"]').waitFor() + // das Speichern geht zu Ende, danach darf aria-busy nicht stehen bleiben + await warteBis(() => version() === v0 + 1, 'Speichern angekommen') + await page.waitForFunction(() => !document.querySelector('#tm-panel').hasAttribute('aria-busy'), null, { timeout: 4000 }) + eq(await page.locator('#tm-panel[aria-busy]').count(), 0, 'aria-busy nach dem Speichern weg') + eq(await page.locator('#tm-panel .tm-tab button:disabled').count() > 0, false, 'die Knöpfe des neuen Reiters sind frei') + eq(await knopf(page, '+ Person oder Platz hinzufügen').isEnabled(), true, 'neuer Reiter bedienbar') + // das Ergebnis des Speicherns ist trotzdem da und der Reiter Leistungen zeigt es + await page.locator('[data-reiter="leistungen"]').click() + assert((await textVon(zeileMitId(page, S_HERREN).locator('.tm-meta').first())).includes('35 Min.'), 'Leistungen zeigen die gespeicherte Dauer') + eq(await page.locator('#tm-panel[aria-busy]').count(), 0, 'weiter kein aria-busy') + }) + // ------------------------------------------------------------ Konflikt mit zweitem Tab await check('echt_konflikt_zweiter_tab', async (page, ctx) => { await oeffneReiter(page, 'einstellungen')