feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
+117
-50
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user