diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 65e92e760c..1d7eda8693 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -28,6 +28,11 @@ } }, "settings": { + "username": { + "save": "Save username", + "saving": "Saving…", + "help": "2–40 lowercase letters, numbers, or hyphens. Your previous username stays reserved, but old mentions do not notify you." + }, "avatar": { "regenerate": "Regenerate", "uploadPhoto": "Upload photo", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 5c1f9423c3..c3f8a34037 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -28,6 +28,11 @@ } }, "settings": { + "username": { + "save": "保存用户名", + "saving": "保存中…", + "help": "2–40个小写字母、数字或连字符。你的旧用户名会继续保留,但旧提及不会通知你。" + }, "avatar": { "regenerate": "换一个", "uploadPhoto": "上传照片", diff --git a/frontend/src/v2/__tests__/V2SettingsPage.test.tsx b/frontend/src/v2/__tests__/V2SettingsPage.test.tsx index ac9452dfe3..69eb71331f 100644 --- a/frontend/src/v2/__tests__/V2SettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2SettingsPage.test.tsx @@ -210,6 +210,53 @@ describe('V2SettingsPage', () => { expect(screen.getByText(/previous username stays reserved/i)).toBeInTheDocument(); }); + test('submits the username rename from Enter without submitting the display-name form', async () => { + (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); + auth.updateUsername.mockResolvedValue({ ...auth.currentUser, username: 'lily-shen' }); + renderSettings(); + + const usernameInput = screen.getByLabelText('Username'); + fireEvent.change(usernameInput, { target: { value: 'lily-shen' } }); + fireEvent.keyDown(usernameInput, { key: 'Enter', code: 'Enter' }); + + await waitFor(() => expect(auth.updateUsername).toHaveBeenCalledWith('lily-shen')); + expect(auth.updateProfile).not.toHaveBeenCalled(); + expect(screen.queryByText('No account changes to save.')).not.toBeInTheDocument(); + }); + + test('keeps the saved username in the header when a rename is rejected', async () => { + (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); + auth.updateUsername.mockRejectedValueOnce({ response: { data: { error: 'Username is already taken.' } } }); + renderSettings(); + + const usernameInput = screen.getByLabelText('Username'); + fireEvent.change(usernameInput, { target: { value: 'taken-name' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save username' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Username is already taken.'); + expect(screen.getByText('@lily')).toBeInTheDocument(); + expect(screen.queryByText('@taken-name')).not.toBeInTheDocument(); + }); + + test('localizes the username save controls and help in Chinese', async () => { + await i18nReady; + (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); + let finishUpdate: ((value: unknown) => void) | undefined; + auth.updateUsername.mockReturnValue(new Promise((resolve) => { finishUpdate = resolve; })); + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + const view = renderSettings(); + + expect(screen.getByRole('button', { name: '保存用户名' })).toBeInTheDocument(); + expect(screen.getByText(/你的旧用户名会继续保留/)).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'lily-shen' } }); + fireEvent.click(screen.getByRole('button', { name: '保存用户名' })); + expect(screen.getByRole('button', { name: '保存中…' })).toBeDisabled(); + + await act(async () => { finishUpdate?.({ ...auth.currentUser, username: 'lily-shen' }); }); + view.unmount(); + await act(async () => { await i18n.changeLanguage('en'); }); + }); + test('Regenerate saves the next Paper seed instead of repeating the current look', async () => { (axios.get as jest.Mock).mockResolvedValue({ data: { hasToken: false } }); auth.updateProfile.mockResolvedValue({ ...auth.currentUser, profilePicture: 'paper:u1-v1' }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index d83b23409f..2f6fad8a32 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -1006,6 +1006,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(settingsPage).not.toContain('Email changes go through re-verification; ask us for now.'); expect(settingsPage).not.toContain('v2-settings__nav-note'); expect(ruleBody(v2, '.v2-settings__account-fields')).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))'); + expect(ruleBody(v2, '.v2-settings__account-fields > label')).toContain('align-content: start'); expect(ruleBody(v2, '.v2-settings__account-fields > .v2-settings__account-email')).toContain('grid-column: 1 / -1'); expect(ruleBody(v2, '.v2-settings__account-fields input')).toContain('border-radius: var(--v2-radius-sm)'); expect(ruleBody(v2, '.v2-settings__email-control')).toContain('display: flex'); diff --git a/frontend/src/v2/components/V2SettingsPage.tsx b/frontend/src/v2/components/V2SettingsPage.tsx index 7f0d9d745d..2bfea73dec 100644 --- a/frontend/src/v2/components/V2SettingsPage.tsx +++ b/frontend/src/v2/components/V2SettingsPage.tsx @@ -74,6 +74,7 @@ const V2AccountSection: React.FC = () => { || (!profilePicture.startsWith('paper:') && Boolean(getAvatarSrc(profilePicture))); const [name, setName] = useState(accountName); const [username, setUsername] = useState(accountUsername); + const [savedUsername, setSavedUsername] = useState(accountUsername); const [email, setEmail] = useState(accountEmail); const [busy, setBusy] = useState(false); const [usernameBusy, setUsernameBusy] = useState(false); @@ -89,6 +90,7 @@ const V2AccountSection: React.FC = () => { useEffect(() => { setName(accountName); setUsername(accountUsername); + setSavedUsername(accountUsername); setEmail(accountEmail); }, [accountEmail, accountName, accountUsername]); @@ -136,6 +138,7 @@ const V2AccountSection: React.FC = () => { || nextUsername, ); setUsername(updatedUsername); + setSavedUsername(updatedUsername); setNotice('Username saved.'); } catch (requestError) { const message = (requestError as { response?: { data?: { error?: string; msg?: string } } })?.response?.data; @@ -145,6 +148,12 @@ const V2AccountSection: React.FC = () => { } }; + const handleUsernameKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + if (!usernameBusy) void saveUsername(); + }; + const choosePhoto = () => fileInputRef.current?.click(); const regenerateAvatar = async () => { @@ -217,7 +226,7 @@ const V2AccountSection: React.FC = () => { />
{name || accountName}
-
@{username}
+
@{savedUsername}
{currentUser?.role === 'admin' ? 'administrator' : 'member'}
@@ -268,6 +277,7 @@ const V2AccountSection: React.FC = () => { aria-label="Username" value={username} onChange={(event) => setUsername(event.target.value)} + onKeyDown={handleUsernameKeyDown} minLength={2} maxLength={40} pattern="[a-z0-9][a-z0-9-]{1,39}" @@ -280,11 +290,11 @@ const V2AccountSection: React.FC = () => { onClick={() => { if (!usernameBusy) void saveUsername(); }} disabled={usernameBusy} > - {usernameBusy ? 'Saving…' : 'Save username'} + {usernameBusy ? t('settings.username.saving') : t('settings.username.save')} - 2–40 lowercase letters, numbers, or hyphens. Your previous username stays reserved, but old mentions do not notify you. + {t('settings.username.help')}
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index a2ac7a38e8..78d49ed1dc 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -7754,6 +7754,10 @@ body.modern-ui.v2-canvas { line-height: var(--v2-lh-meta); } +.v2-settings__account-fields > label { + align-content: start; +} + .v2-settings__account-fields > .v2-settings__account-email { grid-column: 1 / -1; }