Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@
}
},
"settings": {
"username": {
"save": "保存用户名",
"saving": "保存中…",
"help": "2–40个小写字母、数字或连字符。你的旧用户名会继续保留,但旧提及不会通知你。"
},
"avatar": {
"regenerate": "换一个",
"uploadPhoto": "上传照片",
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/v2/__tests__/V2SettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
1 change: 1 addition & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
16 changes: 13 additions & 3 deletions frontend/src/v2/components/V2SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -89,6 +90,7 @@ const V2AccountSection: React.FC = () => {
useEffect(() => {
setName(accountName);
setUsername(accountUsername);
setSavedUsername(accountUsername);
setEmail(accountEmail);
}, [accountEmail, accountName, accountUsername]);

Expand Down Expand Up @@ -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;
Expand All @@ -145,6 +148,12 @@ const V2AccountSection: React.FC = () => {
}
};

const handleUsernameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter') return;
event.preventDefault();
if (!usernameBusy) void saveUsername();
};

const choosePhoto = () => fileInputRef.current?.click();

const regenerateAvatar = async () => {
Expand Down Expand Up @@ -217,7 +226,7 @@ const V2AccountSection: React.FC = () => {
/>
<div className="v2-settings__account-copy">
<div className="v2-settings__account-name">{name || accountName}</div>
<div className="v2-settings__meta">@{username}</div>
<div className="v2-settings__meta">@{savedUsername}</div>
</div>
<div className="v2-settings__account-type">{currentUser?.role === 'admin' ? 'administrator' : 'member'}</div>
</div>
Expand Down Expand Up @@ -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}"
Expand All @@ -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')}
</button>
</div>
<span className="v2-settings__field-help" id="v2-settings-username-help">
2–40 lowercase letters, numbers, or hyphens. Your previous username stays reserved, but old mentions do not notify you.
{t('settings.username.help')}
</span>
</label>
<div className="v2-settings__account-email">
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading