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:
1 parent
53bcecf3cb
commit
3440eb46c3
4 files changed
+187
-15
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user