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(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
get: vi.fn(),
|
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 () => {
|
it('scheitert das Speichern des Namens (Rennen, Netz), ist man trotzdem angemeldet: der Dialog schließt mit true, der Namensdialog erscheint', async () => {
|
||||||
serverAccepts();
|
serverAccepts();
|
||||||
profileMocks.saveUsername.mockRejectedValue(new Error('409'));
|
const failure = new Error('409');
|
||||||
|
profileMocks.saveUsername.mockRejectedValue(failure);
|
||||||
const result = requireLogin('register');
|
const result = requireLogin('register');
|
||||||
fill(fields());
|
fill(fields());
|
||||||
submit();
|
submit();
|
||||||
await expect(result).resolves.toBe(true);
|
await expect(result).resolves.toBe(true);
|
||||||
expect(dialogEl().open).toBe(false);
|
expect(dialogEl().open).toBe(false);
|
||||||
expect(mocks.get).toHaveBeenCalled(); // angemeldet
|
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 () => {
|
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');
|
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] : []));
|
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 {
|
function showFieldError(panel: Panel, input: HTMLInputElement, text: string): void {
|
||||||
showError(panel, text);
|
showError(panel, text);
|
||||||
setFieldInvalid(input, ERROR_ID, true);
|
setFieldInvalid(input, ERROR_ID, true);
|
||||||
input.focus();
|
input.focus();
|
||||||
|
panel.errorEl.scrollIntoView({ block: 'nearest' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function showOk(panel: Panel, text: string): void {
|
function showOk(panel: Panel, text: string): void {
|
||||||
@@ -244,15 +249,18 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
|||||||
if (!asked) clearUsernamePrompted();
|
if (!asked) clearUsernamePrompted();
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
let failure: unknown;
|
||||||
let saved = true;
|
let saved = true;
|
||||||
try {
|
try {
|
||||||
await saveUsername(name);
|
await saveUsername(name);
|
||||||
} catch {
|
} catch (err) {
|
||||||
saved = false;
|
saved = false;
|
||||||
|
failure = err;
|
||||||
}
|
}
|
||||||
finish(true);
|
finish(true);
|
||||||
if (!saved) {
|
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);
|
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', () => {
|
describe('Busy-Sperre beim Speichern', () => {
|
||||||
it('während des Speicherns: Knöpfe und Feld gesperrt, Status sichtbar, ein zweites Absenden wird ignoriert', async () => {
|
it('während des Speicherns: Knöpfe und Feld gesperrt, Status sichtbar, ein zweites Absenden wird ignoriert', async () => {
|
||||||
let finish!: (name: string) => void;
|
let finish!: (name: string) => void;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export interface UsernameDialogOptions {
|
|||||||
mode: UsernameDialogMode;
|
mode: UsernameDialogMode;
|
||||||
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
|
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
|
||||||
signal?: AbortSignal;
|
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`). */
|
/** 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-required': 'true',
|
||||||
'aria-describedby': 'username-name-hint',
|
'aria-describedby': 'username-name-hint',
|
||||||
});
|
});
|
||||||
input.value = current ?? '';
|
input.value = opts.initial ?? current ?? '';
|
||||||
const field = el(
|
const field = el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'auth-field' },
|
{ class: 'auth-field' },
|
||||||
@@ -128,6 +132,16 @@ function build(opts: UsernameDialogOptions): Promise<string | null> {
|
|||||||
input.focus();
|
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> {
|
async function submit(): Promise<void> {
|
||||||
setFieldInvalid(input, ERROR_ID, false);
|
setFieldInvalid(input, ERROR_ID, false);
|
||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
@@ -143,12 +157,7 @@ function build(opts: UsernameDialogOptions): Promise<string | null> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
if (err instanceof ApiError && err.serverCode !== null && FIELD_CODES.has(err.serverCode)) {
|
showFailure(err);
|
||||||
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();
|
|
||||||
}
|
|
||||||
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -197,7 +206,8 @@ function build(opts: UsernameDialogOptions): Promise<string | null> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
input.focus();
|
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,
|
* 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.
|
* 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
|
* 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> {
|
export function usernameDialog(opts: UsernameDialogOptions): Promise<string | null> {
|
||||||
if (opts.signal?.aborted) return Promise.resolve(null);
|
if (opts.signal?.aborted) return Promise.resolve(null);
|
||||||
|
|||||||
Reference in new issue
Block a user