feat(web): профиль с аватаром, панель в углу, настройки сервера, скрытые ID

Правки пользователя:

- в «Профиль → Основной» сверху квадратный аватар слева, справа от него
  настройки имени, «О себе», состояния и эмодзи во всю высоту; смена аватара —
  клик по нему, форматы (JPEG, PNG, WebP, GIF до 8 МБ) — в подсказке;
- убраны дубли: часовой пояс только в «Язык и регион», статус — только в
  мини-профиле панели;
- панель пользователя переехала в нижний левый угол под сайдбар, подсказка о
  выходе из сервера — на её прежнее место; панель ужата под 240 px;
- шестерёнка сервера открывает новый пункт настроек «Сервер» (список серверов,
  общее, участники, роли, опасная зона), а «Профиль → Сервер» оставлен только
  для ника и «Применить ко всем серверам»;
- окно настроек получило статический размер 70% рабочей области со скроллом;
- ID пользователей скрыты везде, кроме раздела «Инстанс» у администратора;
- список друзей свёрнут под кнопкой «Друзья (N)» и раскрывается снизу вверх;
- 249 тестов Vitest (было 239), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 11:24:09 +03:00
parent bd5f09336b
commit 4c8f888b3e
27 changed files with 1071 additions and 419 deletions
+213 -16
View File
@@ -35,13 +35,46 @@ const sessions = [
},
];
/** Ответы оболочки приложения: профиль, серверы, инстанс. */
/**
* Ответы оболочки приложения: профиль, серверы, инстанс. Дополнительные
* маршруты идут первыми — они должны побеждать при одинаковом шаблоне.
*/
function appRoutes(current = user, extra: FetchRoute[] = []): FetchRoute[] {
return [
...extra,
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user: current }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
...extra,
];
}
/** Сводка сервера для ответов `GET /guilds/{id}`. */
function guildDetail(id: string, name: string, ownerId: string, permissions: string[]) {
return {
id,
name,
description: '',
owner_id: ownerId,
is_main: false,
member_count: 1,
roles: [],
my_role_ids: [],
my_permissions: permissions,
};
}
/** Серверы пользователя: «Альфа» своя, «Бета» с правом MANAGE_GUILD. */
function guildRoute(): FetchRoute[] {
const alpha = guildDetail('g-1', 'Альфа', 'user-1', []);
const beta = guildDetail('g-2', 'Бета', 'user-9', ['MANAGE_GUILD']);
return [
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-2/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-2/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1', response: () => json({ guild: alpha }) },
{ match: '/api/v1/guilds/g-2', response: () => json({ guild: beta }) },
];
}
@@ -52,6 +85,16 @@ async function openSettings(): Promise<HTMLElement> {
return screen.findByRole('dialog', { name: 'Настройки' });
}
/** Список пунктов окна настроек. */
function settingsNav(dialog: HTMLElement): HTMLElement {
return within(dialog).getByTestId('settings-nav');
}
/** Открывает пункт «Сервер» (администрирование) в левом меню настроек. */
async function openServerSection(dialog: HTMLElement): Promise<void> {
await userEvent.click(await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }));
}
/** Переходит к пункту настроек по его названию в левом меню. */
async function goToSection(dialog: HTMLElement, name: string): Promise<void> {
await userEvent.click(within(dialog).getByRole('button', { name }));
@@ -136,6 +179,41 @@ describe('настройки: окно и навигация', () => {
expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible();
});
it('пункт «Сервер» видят только те, кто может управлять сервером', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
// Ни своего сервера, ни прав администратора инстанса — пункта нет.
expect(within(settingsNav(dialog)).queryByRole('button', { name: 'Сервер' })).toBeNull();
});
it('владелец сервера открывает администрирование этого сервера', async () => {
installFetch(appRoutes(user, guildRoute()));
renderApp('/app/empty');
const dialog = await openSettings();
// Пункт появляется, когда снапшот сессии узнал серверы пользователя.
await openServerSection(dialog);
// Выбран сервер пользователя: его общие настройки и «Опасная зона».
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа');
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Альфа');
expect(within(dialog).getByRole('button', { name: 'Удалить сервер' })).toBeVisible();
});
it('администратор инстанса видит пункт «Сервер» без своих серверов', async () => {
installFetch(appRoutes(makeUser({ is_instance_admin: true })));
renderApp('/app/empty');
const dialog = await openSettings();
expect(
await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }),
).toBeVisible();
});
it('пункт «Инстанс» видят только администраторы', async () => {
installFetch(appRoutes());
@@ -230,16 +308,27 @@ describe('настройки: старые адреса', () => {
);
});
it('/settings/servers открывает вкладку «Сервер»', async () => {
installFetch(appRoutes());
it('/settings/servers открывает пункт «Сервер» администрирования', async () => {
installFetch(appRoutes(user, guildRoute()));
renderApp('/settings/servers');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }),
).toHaveAttribute('aria-current', 'true');
// Открылся первый сервер списка.
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа');
});
it('/settings/servers/g-2 открывает администрирование выбранного сервера', async () => {
installFetch(appRoutes(user, guildRoute()));
renderApp('/settings/servers/g-2');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Бета');
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Бета');
});
});
@@ -324,29 +413,137 @@ describe('настройки: аватар', () => {
expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше допустимого размера');
});
it('часовой пояс уходит в PATCH вместе с профилем', async () => {
it('профиль сохраняется без часового пояса и статуса: у них свои места', async () => {
const fetchMock = installFetch(
appRoutes(user, [
{
match: '/api/v1/users/@me',
method: 'PATCH',
response: () => json({ user: { ...user, timezone: 'Asia/Tokyo' } }),
response: () => json({ user }),
},
]),
);
renderApp('/app/empty');
await openSettings();
const dialog = await openSettings();
await userEvent.selectOptions(screen.getByLabelText('Часовой пояс'), 'Asia/Tokyo');
await userEvent.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(within(dialog).queryByLabelText('Часовой пояс')).toBeNull();
expect(within(dialog).queryByLabelText('Статус присутствия')).toBeNull();
// ID не показываем и не копируем в пользовательских настройках.
expect(within(dialog).queryByText('ID пользователя')).toBeNull();
expect(within(dialog).queryByRole('button', { name: 'Скопировать ID' })).toBeNull();
expect(within(dialog).getByLabelText('Отображаемое имя')).toHaveValue('Alice');
await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toMatchObject({
timezone: 'Asia/Tokyo',
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({
display_name: 'Alice',
bio: '',
custom_status: '',
custom_status_emoji: '',
});
});
expect(await screen.findByText('Профиль сохранён.')).toBeVisible();
});
it('часовой пояс есть ровно в одном пункте — «Язык и регион»', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
for (const item of ['Профиль', 'Внешний вид', 'Уведомления', 'Аудио-видео']) {
await goToSection(dialog, item);
expect(within(dialog).queryByLabelText('Часовой пояс')).toBeNull();
}
await goToSection(dialog, 'Язык и регион');
expect(within(dialog).getAllByLabelText('Часовой пояс')).toHaveLength(1);
});
it('аватар стоит слева от полей, клик по нему открывает выбор файла', async () => {
const fetchMock = installFetch(
appRoutes(user, [
{
match: '/api/v1/users/@me/avatar',
method: 'POST',
response: () => json({ user: { ...user, avatar_file_id: 'file-9' } }),
},
]),
);
renderApp('/app/empty');
const dialog = await openSettings();
// Аватар — слева: кнопка смены идёт в DOM раньше поля имени.
const avatar = within(dialog).getByTestId('profile-avatar');
const displayName = within(dialog).getByLabelText('Отображаемое имя');
expect(avatar.compareDocumentPosition(displayName)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
// Подсказка о форматах — в title и доступном имени кнопки, а не текстом.
const change = within(dialog).getByRole('button', { name: /Сменить аватар/ });
expect(change).toHaveAttribute('title', expect.stringContaining('JPEG'));
expect(within(dialog).queryByText(/PNG, JPEG, WebP/)).toBeNull();
// Клик по аватару открывает выбор файла: он же отправляет multipart.
const input = within(dialog).getByLabelText('Выбрать файл аватара');
await userEvent.upload(input, new File(['x'], 'avatar.png', { type: 'image/png' }));
expect(change).toBeVisible();
expect(document.querySelector('input[type="file"]')).not.toBeNull();
await waitFor(() => {
expect(avatarForm(fetchMock, 'POST')).not.toBeNull();
});
});
it('размер окна настроек фиксирован и не зависит от содержимого', async () => {
installFetch(appRoutes());
renderApp('/app/empty');
const dialog = await openSettings();
const panel = dialog;
const style = panel.getAttribute('style') ?? '';
expect(style).toContain('clamp(20rem, 70vw, 72rem)');
expect(style).toContain('clamp(22rem, 70vh, 52rem)');
expect(panel).toHaveClass('overflow-hidden');
expect(screen.getByTestId('settings-content')).toHaveClass('overflow-y-auto');
// Переключение пункта не меняет размеры окна.
const before = panel.getAttribute('style');
await goToSection(dialog, 'Безопасность');
expect(panel.getAttribute('style')).toBe(before);
});
it('ID пользователя в «Инстансе» виден администратору', async () => {
installFetch(
appRoutes(makeUser({ is_instance_admin: true }), [
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
{ match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) },
{
match: '/api/v1/instance/users',
response: () =>
json({
users: [
{
id: 'u-42',
username: 'alice',
display_name: 'Alice',
is_instance_admin: true,
created_at: '2026-09-01T00:00:00Z',
},
],
}),
},
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
]),
);
renderApp('/app/empty');
const dialog = await openSettings();
await goToSection(dialog, 'Инстанс');
expect(await within(dialog).findByTestId('instance-user-id-u-42')).toHaveTextContent('u-42');
expect(within(dialog).getByText(/ID пользователей видны только администратору/)).toBeVisible();
});
});