feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown

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

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 01:32:11 +03:00
parent f26728113d
commit 20eed227ca
31 changed files with 2731 additions and 478 deletions
+117 -50
View File
@@ -91,6 +91,9 @@ function serversRoutes(extra: FetchRoute[] = []): FetchRoute[] {
return [
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [] }) },
{ match: '/api/v1/guilds/g-2/channels', response: () => json({ channels: [] }) },
{ 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/members', response: () => json({ members }) },
@@ -107,61 +110,70 @@ function serversRoutes(extra: FetchRoute[] = []): FetchRoute[] {
];
}
/** Первая кнопка «Сохранить» на странице (секция «Общее»). */
function saveButton(index = 0): HTMLElement {
const buttons = screen.getAllByRole('button', { name: 'Сохранить' });
const button = buttons[index];
if (button === undefined) {
throw new Error('кнопка сохранения не найдена');
/** Кнопка «Сохранить» внутри формы конкретного поля. */
function saveNear(label: string): HTMLElement {
const field = screen.getByLabelText(label);
const form = field.closest('form');
if (form === null) {
throw new Error(`форма для «${label}» не найдена`);
}
return button;
return within(form).getByRole('button', { name: 'Сохранить' });
}
/**
* Открывает настройки сервера: приложение → мини-профиль → «Настройки серверов».
* Так же их открывает и обычный участник без прав администратора.
*/
async function openServerSettings(path = '/app/empty', guildName?: string) {
const view = renderApp(path);
const trigger = await screen.findByLabelText('Открыть меню пользователя');
await userEvent.click(trigger);
const menu = await screen.findByRole('menu', { name: 'Меню пользователя' });
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки серверов' }));
await screen.findByRole('dialog', { name: 'Настройки' });
if (guildName !== undefined) {
const picker = await openGuildPicker();
await userEvent.click(within(picker).getByRole('menuitemradio', { name: guildName }));
}
return view;
}
/** Раскрывает выпадающий список серверов во вкладке «Профиль → Сервер». */
async function openGuildPicker(): Promise<HTMLElement> {
await userEvent.click(await screen.findByLabelText('Выбрать сервер'));
return screen.findByRole('menu', { name: 'Мои серверы' });
}
describe('настройки серверов', () => {
it('показывает список серверов с иконкой и именем и выбирает первый', async () => {
it('показывает выпадающий список серверов с иконкой и именем и выбирает первый', async () => {
installFetch(serversRoutes());
const { router } = renderApp('/settings/servers');
await openServerSettings();
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers/g-1');
});
// Первый сервер выбран по умолчанию.
expect(await screen.findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа');
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
// Первый сервер подсвечен. Список запрашиваем заново: после выбора
// страница перерисовывается, и прежний узел может быть уже отсоединён.
await waitFor(() => {
const picker = screen.getByTestId('guild-picker');
expect(
within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' }),
).toHaveAttribute('aria-current', 'page');
});
const picker = screen.getByTestId('guild-picker');
const first = within(picker).getByRole('link', { name: 'Открыть настройки сервера Альфа' });
const picker = await openGuildPicker();
const first = within(picker).getByRole('menuitemradio', { name: 'Альфа' });
expect(first).toHaveAttribute('aria-checked', 'true');
// Иконка сервера отдаётся файловым сервисом по icon_file_id.
expect(first.querySelector('img')?.getAttribute('src')).toBe('/files/icon-1');
expect(
within(picker).getByRole('link', { name: 'Открыть настройки сервера Бета' }),
).toBeVisible();
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
expect(within(picker).getByRole('menuitemradio', { name: 'Бета' })).toBeVisible();
});
it('переключение сервера меняет отображаемые настройки и права', async () => {
installFetch(serversRoutes());
const { router } = renderApp('/settings/servers/g-1');
await openServerSettings();
expect(await screen.findByLabelText('Название сервера')).toHaveValue('Альфа');
// Владелец может редактировать.
expect(screen.getByLabelText('Название сервера')).toBeEnabled();
await userEvent.click(
await screen.findByRole('link', { name: 'Открыть настройки сервера Бета' }),
);
const picker = await openGuildPicker();
await userEvent.click(within(picker).getByRole('menuitemradio', { name: 'Бета' }));
await waitFor(() => {
expect(router.state.location.pathname).toBe('/settings/servers/g-2');
});
await waitFor(() => {
expect(screen.getByLabelText('Название сервера')).toHaveValue('Бета');
});
@@ -181,7 +193,7 @@ describe('настройки серверов', () => {
]),
);
renderApp('/settings/servers/g-1');
await openServerSettings();
const name = await screen.findByLabelText('Название сервера');
await userEvent.clear(name);
@@ -189,7 +201,7 @@ describe('настройки серверов', () => {
const description = screen.getByLabelText('Описание');
await userEvent.clear(description);
await userEvent.type(description, 'Новое');
await userEvent.click(saveButton());
await userEvent.click(saveNear('Название сервера'));
expect(await screen.findByText('Настройки сервера сохранены.')).toBeVisible();
expect(findRequest(fetchMock, { url: '/guilds/g-1', method: 'PATCH' })?.body).toEqual({
@@ -209,7 +221,7 @@ describe('настройки серверов', () => {
]),
);
renderApp('/settings/servers/g-1');
await openServerSettings();
const nickname = await screen.findByLabelText('Никнейм');
await waitFor(() => {
@@ -218,8 +230,7 @@ describe('настройки серверов', () => {
await userEvent.clear(nickname);
await userEvent.type(nickname, 'Алиса');
// Вторая кнопка «Сохранить» — секция «Мой профиль на сервере».
await userEvent.click(saveButton(1));
await userEvent.click(saveNear('Никнейм'));
expect(await screen.findByText('Никнейм сохранён.')).toBeVisible();
const patch = recordedRequests(fetchMock).find(
@@ -228,10 +239,47 @@ describe('настройки серверов', () => {
expect(patch?.body).toEqual({ nickname: 'Алиса' });
});
it('применяет введённый ник ко всем серверам по очереди', async () => {
const fetchMock = installFetch(
serversRoutes([
{
match: '/api/v1/guilds/g-1/members/user-1',
method: 'PATCH',
response: () => json({ ok: true }),
},
{
match: '/api/v1/guilds/g-2/members/user-1',
method: 'PATCH',
response: () => json({ ok: true }),
},
]),
);
await openServerSettings();
const nickname = await screen.findByLabelText('Никнейм');
await userEvent.clear(nickname);
await userEvent.type(nickname, 'Алиса');
await userEvent.click(screen.getByRole('button', { name: 'Применить ко всем серверам' }));
expect(await screen.findByText('Никнейм применён на всех серверах (2).')).toBeVisible();
const patches = recordedRequests(fetchMock).filter(
(request) => request.url.includes('/members/user-1') && request.method === 'PATCH',
);
expect(patches.map((request) => request.url.split('/api/v1')[1])).toEqual([
'/guilds/g-1/members/user-1',
'/guilds/g-2/members/user-1',
]);
expect(
patches.every((request) => JSON.stringify(request.body) === '{"nickname":"Алиса"}'),
).toBe(true);
});
it('показывает участников и роли сервера', async () => {
installFetch(serversRoutes());
renderApp('/settings/servers/g-1');
await openServerSettings();
const list = await screen.findByTestId('guild-members');
expect(within(list).getByText('Аля')).toBeVisible();
@@ -246,7 +294,7 @@ describe('настройки серверов', () => {
it('владелец не может покинуть сервер, но может его удалить', async () => {
installFetch(serversRoutes());
renderApp('/settings/servers/g-1');
await openServerSettings();
expect(await screen.findByText(/Владелец не может покинуть свой сервер/)).toBeVisible();
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
@@ -256,7 +304,7 @@ describe('настройки серверов', () => {
it('участник без прав не видит удаление сервера', async () => {
installFetch(serversRoutes());
renderApp('/settings/servers/g-2');
await openServerSettings('/app/empty', 'Бета');
expect(await screen.findByRole('button', { name: 'Покинуть сервер' })).toBeVisible();
expect(screen.queryByRole('button', { name: 'Удалить сервер' })).toBeNull();
@@ -265,15 +313,32 @@ describe('настройки серверов', () => {
it('окно подтверждения закрывается по «Отмена» после обратной анимации', async () => {
installFetch(serversRoutes());
renderApp('/settings/servers/g-2');
await openServerSettings('/app/empty', 'Бета');
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
await userEvent.click(within(dialog).getByRole('button', { name: 'Отмена' }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).toBeNull();
expect(screen.queryByRole('dialog', { name: 'Покинуть сервер?' })).toBeNull();
});
// Окно настроек при этом остаётся открытым.
expect(screen.getByRole('dialog', { name: 'Настройки' })).toBeVisible();
});
it('Escape закрывает только верхнее окно подтверждения', async () => {
installFetch(serversRoutes());
await openServerSettings('/app/empty', 'Бета');
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'Покинуть сервер?' })).toBeNull();
});
expect(screen.getByRole('dialog', { name: 'Настройки' })).toBeVisible();
});
it('покинуть сервер можно после подтверждения в модальном окне', async () => {
@@ -283,7 +348,7 @@ describe('настройки серверов', () => {
]),
);
const { router } = renderApp('/settings/servers/g-2');
const { router } = await openServerSettings('/app/empty', 'Бета');
await userEvent.click(await screen.findByRole('button', { name: 'Покинуть сервер' }));
const dialog = await screen.findByRole('dialog', { name: 'Покинуть сервер?' });
@@ -303,7 +368,7 @@ describe('настройки серверов', () => {
]),
);
const { router } = renderApp('/settings/servers/g-1');
const { router } = await openServerSettings();
await userEvent.click(await screen.findByRole('button', { name: 'Удалить сервер' }));
const dialog = await screen.findByRole('dialog', { name: 'Удалить сервер?' });
@@ -319,11 +384,13 @@ describe('настройки серверов', () => {
installFetch([
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
]);
renderApp('/settings/servers');
await openServerSettings();
expect(await screen.findByText('Пока нет ни одного сервера')).toBeVisible();
expect(screen.getByText('У вас пока нет серверов.')).toBeVisible();
const content = screen.getByTestId('settings-content');
expect(await within(content).findByText('Пока нет ни одного сервера')).toBeVisible();
expect(within(content).getByText('У вас пока нет серверов.')).toBeVisible();
});
});