diff --git a/web/src/account/login-dialog.test.ts b/web/src/account/login-dialog.test.ts index 577bf44..9de1fc0 100644 --- a/web/src/account/login-dialog.test.ts +++ b/web/src/account/login-dialog.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ get: vi.fn(), @@ -443,14 +443,29 @@ describe('Registrieren: Benutzername', () => { it('scheitert das Speichern des Namens (Rennen, Netz), ist man trotzdem angemeldet: der Dialog schließt mit true, der Namensdialog erscheint', async () => { serverAccepts(); - profileMocks.saveUsername.mockRejectedValue(new Error('409')); + const failure = new Error('409'); + profileMocks.saveUsername.mockRejectedValue(failure); const result = requireLogin('register'); fill(fields()); submit(); await expect(result).resolves.toBe(true); expect(dialogEl().open).toBe(false); expect(mocks.get).toHaveBeenCalled(); // angemeldet - expect(profileMocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'prompt' }); + // Der Namensdialog öffnet mit dem getippten Namen und dem Grund (er steht im Dialog), nicht leer und stumm. + expect(profileMocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'prompt', initial: 'Kenso', failure }); + }); + + it('der getippte Name geht getrimmt in den Namensdialog, genau der Fehler des Speicherns als Grund', async () => { + serverAccepts(); + const taken = Object.assign(new Error('vergeben'), { code: 409 }); + profileMocks.saveUsername.mockRejectedValue(taken); + const result = requireLogin('register'); + fill({ ...fields(), username: ' Mia.K ' }); + submit(); + await result; + expect(profileMocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Mia.K'); + expect(profileMocks.usernameDialog.mock.calls[0][0]).toMatchObject({ initial: 'Mia.K' }); + expect(profileMocks.usernameDialog.mock.calls[0][0].failure).toBe(taken); }); it('der Namensdialog kommt erst, wenn der Login-Dialog zu ist, und ein Fehler beim Öffnen bleibt ohne Folgen', async () => { @@ -611,3 +626,57 @@ describe('Passwort vergessen', () => { expect(title()).toBe('Anmelden'); }); }); + +describe('Registrieren: die Fehlerzeile bleibt im Sichtbaren', () => { + const fields = () => ({ username: 'Kenso', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' }); + const errorLine = () => dialogEl().querySelector('[role="alert"]'); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('ein Fehler am Namensfeld scrollt die gemeinsame Fehlerzeile (sie steht unter allen Feldern) ins Sichtbare, nur so weit wie nötig', () => { + const scroll = vi.spyOn(Element.prototype, 'scrollIntoView'); + void requireLogin('register'); + fill({ ...fields(), username: 'x' }); + submit(); + expect(alertText()).toBe(INVALID); + expect(scroll).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' }); + expect(scroll.mock.contexts[0]).toBe(errorLine()); + expect(document.activeElement).toBe(input('username')); // der Fokus bleibt am Feld + }); + + it('ein gesperrter Name ebenso, jeder neue Fehler scrollt wieder', () => { + const scroll = vi.spyOn(Element.prototype, 'scrollIntoView'); + void requireLogin('register'); + fill({ ...fields(), username: 'Admin' }); + submit(); + fill({ username: 'ab' }); + submit(); + expect(scroll).toHaveBeenCalledTimes(2); + expect(scroll.mock.contexts.every((node) => node === errorLine())).toBe(true); + }); + + it('„vergeben“ (nach der Abfrage am Dienst) ebenso', async () => { + const scroll = vi.spyOn(Element.prototype, 'scrollIntoView'); + profileMocks.isUsernameTaken.mockResolvedValue(true); + void requireLogin('register'); + fill(fields()); + submit(); + await vi.waitFor(() => expect(alertText()).toBe(TAKEN)); + expect(scroll).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' }); + expect(scroll.mock.contexts[0]).toBe(errorLine()); + }); + + it('ohne Fehler am Feld wird nicht gescrollt (gültige Eingabe, Anmelden-Ansicht)', async () => { + const scroll = vi.spyOn(Element.prototype, 'scrollIntoView'); + mocks.create.mockResolvedValue({}); + mocks.createEmailPasswordSession.mockResolvedValue({}); + mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' }); + const registered = requireLogin('register'); + fill(fields()); + submit(); + await registered; + expect(scroll).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/account/login-dialog.ts b/web/src/account/login-dialog.ts index ad71b93..aa3caa2 100644 --- a/web/src/account/login-dialog.ts +++ b/web/src/account/login-dialog.ts @@ -177,11 +177,16 @@ function createLoginDialog(): (view: LoginView) => Promise { panel.errorEl.replaceChildren(text, ...(extra ? [' ', extra] : [])); } - /** Ein Fehler am Eingabefeld, das ihn verursacht: der Text steht in der Meldungszeile, das Feld gilt als ungültig und bekommt den Fokus. */ + /** + * Ein Fehler am Eingabefeld, das ihn verursacht: der Text steht in der Meldungszeile, das Feld gilt als ungültig und bekommt den Fokus. + * Die Meldungszeile steht unter allen Feldern, bei einem Fehler am ersten (dem Namen) kann sie auf einem kleinen Bildschirm außerhalb + * liegen: sie wird ins Sichtbare gerückt, nur so weit wie nötig. + */ function showFieldError(panel: Panel, input: HTMLInputElement, text: string): void { showError(panel, text); setFieldInvalid(input, ERROR_ID, true); input.focus(); + panel.errorEl.scrollIntoView({ block: 'nearest' }); } function showOk(panel: Panel, text: string): void { @@ -244,15 +249,18 @@ function createLoginDialog(): (view: LoginView) => Promise { if (!asked) clearUsernamePrompted(); throw err; } + let failure: unknown; let saved = true; try { await saveUsername(name); - } catch { + } catch (err) { saved = false; + failure = err; } finish(true); if (!saved) { - usernameDialog({ mode: 'prompt' }).catch((err: unknown) => { + // Der Namensdialog beginnt mit dem getippten Namen und nennt den Grund (vergeben, kein Netz …), statt leer und stumm zu erscheinen. + usernameDialog({ mode: 'prompt', initial: name, failure }).catch((err: unknown) => { console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err); }); } diff --git a/web/src/account/username-dialog.test.ts b/web/src/account/username-dialog.test.ts index 2a02a2d..1336e36 100644 --- a/web/src/account/username-dialog.test.ts +++ b/web/src/account/username-dialog.test.ts @@ -360,6 +360,90 @@ describe('Speichern', () => { }); }); +describe('Vorbelegung und Grund beim Öffnen (der Rückfall nach einer Registrierung, deren letzter Schritt scheiterte)', () => { + it('`initial` belegt das Feld vor, auch im Modus „prompt“ (Überschrift „wählen“, „Später“), als Wert und nie als HTML; der Text ist markiert', () => { + void dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso' }); + expect(input().value).toBe('Kenso'); + expect(dialogEl()!.querySelector('b')).toBeNull(); + expect(heading()).toBe('Benutzername wählen'); + expect(buttonByText('Später')).toBeTruthy(); + expect(document.activeElement).toBe(input()); + expect(input().selectionStart).toBe(0); + expect(input().selectionEnd).toBe('Kenso'.length); + expect(errorText()).toBe(''); // ohne `failure` keine Meldung + }); + + it('`initial` geht dem Namen des Kontos im Modus „change“ vor', () => { + mocks.name = 'Alt'; + void dialog.usernameDialog({ mode: 'change', initial: 'Getippt' }); + expect(input().value).toBe('Getippt'); + expect(heading()).toBe('Benutzername ändern'); + }); + + it('`failure` mit einem Fehler am Namen (vergeben): der Grund steht schon da, am Feld, mit Markierung und Fokus', () => { + void dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') }); + expect(errorText()).toBe(TAKEN); + expect(input().getAttribute('aria-invalid')).toBe('true'); + expect(describedBy()).toContain(errorEl().id); + expect(document.activeElement).toBe(input()); + expect(input().value).toBe('Kenso'); + expect(dialogEl()?.open).toBe(true); + }); + + it('`failure` ohne Bezug zum Namen (Netz, Dienst): der Grund steht als Text, das Feld gilt nicht als ungültig, der Name bleibt zum Wiederholen', () => { + void dialog.usernameDialog({ + mode: 'prompt', + initial: 'Kenso', + failure: new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'), + }); + expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.'); + expect(input().hasAttribute('aria-invalid')).toBe(false); + expect(describedBy()).not.toContain(errorEl().id); + expect(input().value).toBe('Kenso'); + expect(document.activeElement).toBe(input()); + }); + + it('andere Fehler als `failure` werden wie beim Speichern erklärt (kein Netz, abgelaufene Anmeldung, Unbekanntes)', async () => { + void dialog.usernameDialog({ mode: 'prompt', failure: new TypeError('Failed to fetch') }); + expect(errorText()).toBe('Keine Verbindung zum Server.'); + buttonByText('Später')!.click(); + await settle(); + void dialog.usernameDialog({ mode: 'prompt', failure: Object.assign(new Error('x'), { code: 401 }) }); + expect(errorText()).toBe('Bitte melde dich an.'); + buttonByText('Später')!.click(); + await settle(); + void dialog.usernameDialog({ mode: 'prompt', failure: new Error('???') }); + expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); + }); + + it('der Grund verschwindet beim nächsten Speichern; ein anderer Name geht durch', async () => { + mocks.saveUsername.mockResolvedValue('Kenso2'); + const result = dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') }); + input().value = 'Kenso2'; + send(); + expect(errorText()).toBe(''); + expect(input().hasAttribute('aria-invalid')).toBe(false); + await expect(result).resolves.toBe('Kenso2'); + expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso2'); + }); + + it('beides gilt nur für diesen Aufruf: der nächste Dialog beginnt leer und ohne Meldung', async () => { + const first = dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new TypeError('offline') }); + buttonByText('Später')!.click(); + await first; + void dialog.usernameDialog({ mode: 'prompt' }); + expect(input().value).toBe(''); + expect(errorText()).toBe(''); + }); + + it('Englisch: der Grund erscheint in der Sprache der Seite', async () => { + (await import('../i18n/i18n')).setLang('en', { persist: false }); + void dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') }); + expect(errorText()).toBe('This username is already taken.'); + expect(germanLeft(dialogEl()!)).toEqual([]); + }); +}); + describe('Busy-Sperre beim Speichern', () => { it('während des Speicherns: Knöpfe und Feld gesperrt, Status sichtbar, ein zweites Absenden wird ignoriert', async () => { let finish!: (name: string) => void; diff --git a/web/src/account/username-dialog.ts b/web/src/account/username-dialog.ts index f3b607a..dd92a76 100644 --- a/web/src/account/username-dialog.ts +++ b/web/src/account/username-dialog.ts @@ -26,6 +26,10 @@ export interface UsernameDialogOptions { mode: UsernameDialogMode; /** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */ signal?: AbortSignal; + /** Der Name, mit dem das Feld beginnt (geht dem aktuellen Namen im Modus `change` vor), etwa der zuvor getippte nach einem Fehler. */ + initial?: string; + /** Ein Fehler, dessen Grund beim Öffnen schon im Dialog steht, als hätte das Speichern ihn eben gemeldet. */ + failure?: unknown; } /** Der Text zu einem fehlgeschlagenen Speichern in der Sprache der Seite; Fehler des Dienstes über ihren Code (`apiErrorText`). */ @@ -57,7 +61,7 @@ function build(opts: UsernameDialogOptions): Promise { 'aria-required': 'true', 'aria-describedby': 'username-name-hint', }); - input.value = current ?? ''; + input.value = opts.initial ?? current ?? ''; const field = el( 'div', { class: 'auth-field' }, @@ -128,6 +132,16 @@ function build(opts: UsernameDialogOptions): Promise { input.focus(); } + /** Der Grund eines gescheiterten Speicherns: am Namen (vergeben, gesperrt, ungültig) am Feld, sonst (Netz, Dienst, Anmeldung) nur als Text. */ + function showFailure(err: unknown): void { + if (err instanceof ApiError && err.serverCode !== null && FIELD_CODES.has(err.serverCode)) { + showFieldProblem(failureText(err)); + } else { + errorEl.textContent = failureText(err); // der Name kann richtig sein + input.focus(); + } + } + async function submit(): Promise { setFieldInvalid(input, ERROR_ID, false); errorEl.textContent = ''; @@ -143,12 +157,7 @@ function build(opts: UsernameDialogOptions): Promise { } catch (err) { if (closed) return; setBusy(false); - if (err instanceof ApiError && err.serverCode !== null && FIELD_CODES.has(err.serverCode)) { - showFieldProblem(failureText(err)); // vergeben, gesperrt, ungültig: das liegt am Namen - } else { - errorEl.textContent = failureText(err); // Netz, Dienst, Anmeldung: der Name kann richtig sein - input.focus(); - } + showFailure(err); reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein return; } @@ -197,7 +206,8 @@ function build(opts: UsernameDialogOptions): Promise { return; } input.focus(); - if (current) input.select(); // zum Ändern: gleich überschreiben können + if (input.value !== '') input.select(); // zum Ändern (oder nach einem Fehler): gleich überschreiben können + if (opts.failure !== undefined) showFailure(opts.failure); }); } @@ -210,7 +220,8 @@ let running: Promise | null = null; * Während des Speicherns lässt er sich nicht schließen (Esc und Hintergrund wirkungslos), damit niemand glaubt, etwas abgebrochen zu haben, * das dann doch gespeichert wird; nur `signal` schließt immer. * Läuft schon ein Namensdialog (etwa die einmalige Abfrage, wenn gleichzeitig der Dienst „Name nötig“ meldet), teilt sich der Aufruf ihn - * samt Ergebnis, statt einen zweiten darüberzulegen; für einen Aufrufer mit „Name nötig“ ist null dann ebenso ein „nicht weiter“. + * samt Ergebnis, statt einen zweiten darüberzulegen (seine Angaben `signal`, `initial` und `failure` gelten dann nicht); für einen + * Aufrufer mit „Name nötig“ ist null dann ebenso ein „nicht weiter“. */ export function usernameDialog(opts: UsernameDialogOptions): Promise { if (opts.signal?.aborted) return Promise.resolve(null);