fix(account): Registrieren zeigt die Fehlerzeile am Feld im Sichtbaren, der Rückfall-Dialog nennt Namen und Grund

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 07:49:45 +00:00
1 parent 53bcecf3cb
commit 3440eb46c3
4 files changed
+187 -15

No files matched your search

+72 -3
View File
@@ -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();
});
});
+11 -3
View File
@@ -177,11 +177,16 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
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<boolean> {
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);
});
}
+84
View File
@@ -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: '<b>Kenso</b>' });
expect(input().value).toBe('<b>Kenso</b>');
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('<b>Kenso</b>'.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;
+20 -9
View File
@@ -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<string | null> {
'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<string | null> {
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<void> {
setFieldInvalid(input, ERROR_ID, false);
errorEl.textContent = '';
@@ -143,12 +157,7 @@ function build(opts: UsernameDialogOptions): Promise<string | null> {
} 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<string | null> {
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<string | null> | 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<string | null> {
if (opts.signal?.aborted) return Promise.resolve(null);