feat(templates): свои шаблоны в клиенте — создание сервера и настройки (Фаза 7)

Модалка создания сервера показывает блок «Мои шаблоны» рядом со
встроенными и отправляет template_id=personal:<id>; секция «Шаблоны
сервера» в настройках (видна с MANAGE_GUILD) сохраняет структуру
текущего сервера и удаляет ненужные шаблоны.

- api: fetchMyGuildTemplates, createGuildTemplate, deleteGuildTemplate,
  ключ myGuildTemplatesQueryKey и лимит MY_GUILD_TEMPLATE_LIMIT;
- i18n: ключи в ru.json и en.json (паритет);
- тесты: web/tests/guildTemplates.test.tsx.
This commit is contained in:
2026-09-26 16:45:16 +03:00
parent 605b2038f1
commit 96e0cd5918
7 changed files with 625 additions and 32 deletions
+43 -1
View File
@@ -17,7 +17,7 @@ export const guildChannelsQueryKey = (guildId: string) => ['guilds', guildId, 'c
export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const;
export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const;
/** Встроенный шаблон структуры сервера (AGENT.md 7.3). */
/** Шаблон структуры сервера: встроенный или свой (AGENT.md 7.3). */
export interface GuildTemplate {
id: string;
name: string;
@@ -26,9 +26,16 @@ export interface GuildTemplate {
text_channels: number;
voice_channels: number;
categories: string[];
/** Личный шаблон: дата создания и сервер-источник (Фаза 7). */
created_at?: string;
source_guild_id?: string;
}
/** Предел личных шаблонов на пользователя — как на сервере. */
export const MY_GUILD_TEMPLATE_LIMIT = 20;
export const guildTemplatesQueryKey = ['guild-templates'] as const;
export const myGuildTemplatesQueryKey = ['guild-templates', 'mine'] as const;
/** `GET /guild-templates` — встроенные шаблоны серверов. */
export async function fetchGuildTemplates(signal?: AbortSignal): Promise<GuildTemplate[]> {
@@ -39,6 +46,41 @@ export async function fetchGuildTemplates(signal?: AbortSignal): Promise<GuildTe
return payload.templates;
}
/** `GET /guild-templates/mine` — свои шаблоны: видны только владельцу. */
export async function fetchMyGuildTemplates(signal?: AbortSignal): Promise<GuildTemplate[]> {
const payload = await request<{ templates: GuildTemplate[] }>(
'/guild-templates/mine',
signal === undefined ? {} : { signal },
);
return payload.templates;
}
export interface CreateGuildTemplateInput {
guildId: string;
name: string;
description?: string;
}
/** `POST /guild-templates` — сохранить структуру сервера личным шаблоном. */
export async function createGuildTemplate(input: CreateGuildTemplateInput): Promise<GuildTemplate> {
const payload = await request<{ template: GuildTemplate }>('/guild-templates', {
method: 'POST',
body: {
guild_id: input.guildId,
name: input.name,
description: input.description ?? '',
},
});
return payload.template;
}
/** `DELETE /guild-templates/{id}` — удалить свой шаблон. */
export async function deleteGuildTemplate(templateId: string): Promise<void> {
await request<{ ok: boolean }>(`/guild-templates/${encodeURIComponent(templateId)}`, {
method: 'DELETE',
});
}
/** `POST /guilds` — создать сервер по шаблону (пусто — «Пустой»). */
export async function createGuild(name: string, templateId = ''): Promise<GuildSummary> {
const payload = await request<{ guild: GuildSummary }>('/guilds', {
+26 -1
View File
@@ -111,6 +111,9 @@
"emojis": {
"limit_reached": "The server has reached its emoji limit."
},
"template": {
"limit_reached": "You have reached the personal template limit — delete one of them."
},
"voice.disabled": "Voice is not configured on this instance.",
"voice.channel_full": "No free slots in this room.",
"voice.token_refresh_failed": "Could not renew the voice session. Please join again.",
@@ -256,7 +259,9 @@
"limit": "You can create at most {{count}} servers on this instance.",
"limitReached": "You have reached the server limit — remove one of your servers first.",
"template": "Server template",
"templateCounts": "{{text}} text · {{voice}} voice · {{roles}} roles"
"templateCounts": "{{text}} text · {{voice}} voice · {{roles}} roles",
"myTemplates": "My templates",
"myTemplatesEmpty": "No templates yet — save a server structure in its settings."
},
"empty": {
"title": "You have no servers yet",
@@ -1222,6 +1227,26 @@
"role_revoke": "Role revoked"
}
},
"templates": {
"title": "Server templates",
"description": "Save the server structure — roles with permissions and colours, categories and rooms — and create new servers from it. Up to {{limit}} templates, visible only to you.",
"counter": "{{count}} of {{limit}}",
"empty": "You have no templates yet.",
"counts": "{{text}} text · {{voice}} voice · {{roles}} roles",
"saveTitle": "Save structure as a template",
"saveHint": "The template includes roles (except the default one), categories and rooms with their settings. Messages are not saved.",
"name": "Template name",
"nameHint": "Up to 64 characters; empty — the server name “{{name}}”.",
"templateDescription": "Template description",
"descriptionPlaceholder": "What is inside the template",
"save": "Save template",
"saved": "Template saved.",
"delete": "Delete",
"deleteLabel": "Delete template {{name}}",
"deleteConfirmTitle": "Delete template?",
"deleteConfirmBody": "Template “{{name}}” will be deleted. Servers created from it stay.",
"deleteConfirm": "Delete"
},
"bans": {
"title": "Bans",
"description": "Banned users cannot return by invite until the ban is lifted.",
+26 -1
View File
@@ -111,6 +111,9 @@
"emojis": {
"limit_reached": "На сервере достигнут лимит эмодзи."
},
"template": {
"limit_reached": "Достигнут лимит своих шаблонов — удалите один из них."
},
"voice.disabled": "Голос не настроен на инстансе.",
"voice.channel_full": "В комнате нет свободных мест.",
"voice.token_refresh_failed": "Не удалось продлить сессию голоса. Подключитесь заново.",
@@ -256,7 +259,9 @@
"limit": "На инстансе можно создать не более {{count}} серверов.",
"limitReached": "Достигнут лимит серверов — создайте новый после удаления одного из текущих.",
"template": "Шаблон сервера",
"templateCounts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли"
"templateCounts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли",
"myTemplates": "Мои шаблоны",
"myTemplatesEmpty": "Своих шаблонов пока нет — сохраните структуру сервера в его настройках."
},
"empty": {
"title": "У вас пока нет серверов",
@@ -1222,6 +1227,26 @@
"role_revoke": "Снятие роли"
}
},
"templates": {
"title": "Шаблоны сервера",
"description": "Сохраните структуру сервера — роли с правами и цветом, категории и комнаты — и создавайте по ней новые серверы. Своих шаблонов: до {{limit}}, они видны только вам.",
"counter": "{{count}} из {{limit}}",
"empty": "Своих шаблонов пока нет.",
"counts": "{{text}} текст. · {{voice}} голос. · {{roles}} роли",
"saveTitle": "Сохранить структуру как шаблон",
"saveHint": "В шаблон попадут роли (кроме роли по умолчанию), категории и комнаты с их настройками. Сообщения не сохраняются.",
"name": "Название шаблона",
"nameHint": "До 64 символов; пусто — имя сервера «{{name}}».",
"templateDescription": "Описание шаблона",
"descriptionPlaceholder": "Что внутри шаблона",
"save": "Сохранить шаблон",
"saved": "Шаблон сохранён.",
"delete": "Удалить",
"deleteLabel": "Удалить шаблон {{name}}",
"deleteConfirmTitle": "Удалить шаблон?",
"deleteConfirmBody": "Шаблон «{{name}}» будет удалён. Серверы, созданные по нему, останутся.",
"deleteConfirm": "Удалить"
},
"bans": {
"title": "Баны",
"description": "Забаненные не могут вернуться по приглашению, пока бан не снят.",
+89 -29
View File
@@ -3,7 +3,14 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { createGuild, fetchGuildTemplates, guildTemplatesQueryKey } from '@/api/guilds';
import {
createGuild,
fetchGuildTemplates,
fetchMyGuildTemplates,
guildTemplatesQueryKey,
myGuildTemplatesQueryKey,
type GuildTemplate,
} from '@/api/guilds';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
@@ -14,6 +21,51 @@ import { useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
/** Значение радио личного шаблона: так его понимает `POST /guilds` (7.3). */
function personalTemplateValue(templateId: string): string {
return `personal:${templateId}`;
}
interface TemplateOptionProps {
value: string;
testId: string;
template: GuildTemplate;
selected: boolean;
onSelect: (value: string) => void;
}
/** Карточка шаблона в списке выбора: имя, счётчики и описание. */
function TemplateOption({ value, testId, template, selected, onSelect }: TemplateOptionProps) {
const { t } = useTranslation();
return (
<label
data-testid={testId}
className={`flex cursor-pointer flex-col gap-0.5 rounded-[var(--radius-md)] border px-3 py-2 transition-colors duration-[var(--duration-fast)] ${
selected ? 'border-accent bg-accent/10' : 'border-border/60 hover:bg-surface-2'
}`}
>
<span className="flex items-center gap-2">
<input
type="radio"
name="guild_template"
value={value}
checked={selected}
onChange={() => onSelect(value)}
/>
<span className="text-sm font-medium">{template.name}</span>
<span className="ml-auto text-xs text-fg-muted">
{t('guilds.create.templateCounts', {
text: template.text_channels,
voice: template.voice_channels,
roles: template.roles.length,
})}
</span>
</span>
<span className="text-xs text-fg-muted">{template.description}</span>
</label>
);
}
/**
* Единственное модальное окно создания сервера (`POST /guilds {name}`).
* Открывается из рейки и с пустого экрана «У вас пока нет серверов»,
@@ -33,7 +85,7 @@ export function CreateGuildModal() {
const [name, setName] = useState('');
const [templateId, setTemplateId] = useState('empty');
// Шаблоны структуры: список небольшой и статический, кэшируется на сессию.
// Встроенные шаблоны: список небольшой и статический, кэшируется на сессию.
const templates = useQuery({
queryKey: guildTemplatesQueryKey,
queryFn: ({ signal }) => fetchGuildTemplates(signal),
@@ -42,6 +94,15 @@ export function CreateGuildModal() {
retry: 0,
});
// Свои шаблоны (Фаза 7): сервер отдаёт только шаблоны текущего пользователя.
const myTemplates = useQuery({
queryKey: myGuildTemplatesQueryKey,
queryFn: ({ signal }) => fetchMyGuildTemplates(signal),
enabled: open,
staleTime: 60_000,
retry: 0,
});
const create = useMutation({
mutationFn: (guildName: string) => createGuild(guildName, templateId),
onSuccess: (guild) => {
@@ -57,6 +118,8 @@ export function CreateGuildModal() {
const limitReached =
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
const mine = myTemplates.data ?? [];
return (
<Modal
open={open}
@@ -97,35 +160,32 @@ export function CreateGuildModal() {
<fieldset className="flex flex-col gap-2" data-testid="guild-templates">
<legend className="text-sm font-medium">{t('guilds.create.template')}</legend>
{(templates.data ?? []).map((template) => (
<label
<TemplateOption
key={template.id}
data-testid={`guild-template-${template.id}`}
className={`flex cursor-pointer flex-col gap-0.5 rounded-[var(--radius-md)] border px-3 py-2 transition-colors duration-[var(--duration-fast)] ${
templateId === template.id
? 'border-accent bg-accent/10'
: 'border-border/60 hover:bg-surface-2'
}`}
>
<span className="flex items-center gap-2">
<input
type="radio"
name="guild_template"
value={template.id}
checked={templateId === template.id}
onChange={() => setTemplateId(template.id)}
/>
<span className="text-sm font-medium">{template.name}</span>
<span className="ml-auto text-xs text-fg-muted">
{t('guilds.create.templateCounts', {
text: template.text_channels,
voice: template.voice_channels,
roles: template.roles.length,
})}
</span>
</span>
<span className="text-xs text-fg-muted">{template.description}</span>
</label>
value={template.id}
testId={`guild-template-${template.id}`}
template={template}
selected={templateId === template.id}
onSelect={setTemplateId}
/>
))}
<p className="mt-1 text-xs font-medium text-fg-muted" data-testid="my-guild-templates">
{t('guilds.create.myTemplates')}
</p>
{mine.length === 0 ? (
<p className="text-xs text-fg-muted">{t('guilds.create.myTemplatesEmpty')}</p>
) : (
mine.map((template) => (
<TemplateOption
key={template.id}
value={personalTemplateValue(template.id)}
testId={`guild-template-personal-${template.id}`}
template={template}
selected={templateId === personalTemplateValue(template.id)}
onSelect={setTemplateId}
/>
))
)}
{templates.isError ? (
<ErrorNotice error={templates.error} onRetry={() => void templates.refetch()} />
) : null}
@@ -27,6 +27,7 @@ import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
import { GuildPicker } from '@/pages/settings/GuildPicker';
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
import { GuildSoundsSection } from '@/pages/settings/GuildSoundsSection';
import { GuildTemplatesSection } from '@/pages/settings/GuildTemplatesSection';
/**
* Пункт «Сервер» окна настроек: администрирование одного сервера —
@@ -230,6 +231,14 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
{canManageSoundSet ? (
<GuildSoundsSection key={`${guild.id}-sounds`} guildId={guild.id} />
) : null}
{/* Личные шаблоны (Фаза 7): структуру сохраняет тот, у кого MANAGE_GUILD. */}
{canManage ? (
<GuildTemplatesSection
key={`${guild.id}-templates`}
guildId={guild.id}
guildName={guild.name}
/>
) : null}
<GuildBansSection key={`${guild.id}-bans`} guildId={guild.id} canBan={canBan} />
<GuildAuditSection key={`${guild.id}-audit`} guildId={guild.id} canView={canViewAudit} />
<GuildDangerSection
@@ -0,0 +1,210 @@
import { useState, type FormEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
MY_GUILD_TEMPLATE_LIMIT,
createGuildTemplate,
deleteGuildTemplate,
fetchMyGuildTemplates,
myGuildTemplatesQueryKey,
type GuildTemplate,
} from '@/api/guilds';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, TextAreaField } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { SettingsSection } from '@/pages/settings/SettingsSection';
interface GuildTemplatesSectionProps {
guildId: string;
/** Имя сервера — подсказка для имени шаблона (сервер подставит его сам). */
guildName: string;
}
/**
* «Шаблоны сервера»: сохранить структуру текущего сервера (роли с правами и
* цветом, категории и комнаты) личным шаблоном и удалить ненужные (AGENT.md
* 7.3, Фаза 7). Шаблоны приватны: сервер отдаёт только шаблоны владельца.
*/
export function GuildTemplatesSection({ guildId, guildName }: GuildTemplatesSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [saved, setSaved] = useState(false);
const [removing, setRemoving] = useState<GuildTemplate | null>(null);
const list = useQuery({
queryKey: myGuildTemplatesQueryKey,
queryFn: ({ signal }) => fetchMyGuildTemplates(signal),
retry: 0,
});
const templates = list.data ?? [];
const failed = list.isError && list.data === undefined;
const full = templates.length >= MY_GUILD_TEMPLATE_LIMIT;
const create = useMutation({
mutationFn: () =>
createGuildTemplate({
guildId,
name: name.trim(),
description: description.trim(),
}),
onSuccess: () => {
setName('');
setDescription('');
setSaved(true);
void queryClient.invalidateQueries({ queryKey: myGuildTemplatesQueryKey });
},
});
const remove = useMutation({
mutationFn: (templateId: string) => deleteGuildTemplate(templateId),
onSuccess: () => {
setRemoving(null);
void queryClient.invalidateQueries({ queryKey: myGuildTemplatesQueryKey });
},
});
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
setSaved(false);
create.mutate();
};
return (
<SettingsSection
title={t('settings.servers.templates.title')}
description={t('settings.servers.templates.description', { limit: MY_GUILD_TEMPLATE_LIMIT })}
>
<p className="mt-3 text-sm text-fg-muted" data-testid="guild-templates-counter">
{t('settings.servers.templates.counter', {
count: templates.length,
limit: MY_GUILD_TEMPLATE_LIMIT,
})}
</p>
{list.isPending ? (
<SkeletonLines className="mt-4" rows={2} />
) : failed ? (
<ErrorNotice className="mt-4" error={list.error} onRetry={() => void list.refetch()} />
) : templates.length === 0 ? (
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.templates.empty')}</p>
) : (
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="my-guild-templates">
{templates.map((template) => (
<li
key={template.id}
data-testid={`my-guild-template-${template.id}`}
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{template.name}</span>
<span className="truncate text-xs text-fg-muted">
{t('settings.servers.templates.counts', {
text: template.text_channels,
voice: template.voice_channels,
roles: template.roles.length,
})}
</span>
</span>
<Button
variant="ghost"
className="border-danger/50 text-danger"
aria-label={t('settings.servers.templates.deleteLabel', { name: template.name })}
onClick={() => {
remove.reset();
setRemoving(template);
}}
>
{t('settings.servers.templates.delete')}
</Button>
</li>
))}
</ul>
)}
{remove.isError ? <ErrorNotice className="mt-3" error={remove.error} /> : null}
<form
className="mt-5 flex flex-col gap-3 border-t border-border/40 pt-4"
data-testid="guild-templates-form"
onSubmit={onSubmit}
>
<h3 className="text-sm font-semibold">{t('settings.servers.templates.saveTitle')}</h3>
<p className="text-xs text-fg-muted">{t('settings.servers.templates.saveHint')}</p>
{full ? (
<p className="text-xs text-warning" role="status">
{t('errors.template.limit_reached')}
</p>
) : null}
<Field
label={t('settings.servers.templates.name')}
name="guild_template_name"
value={name}
maxLength={64}
placeholder={guildName}
hint={t('settings.servers.templates.nameHint', { name: guildName })}
onChange={(event) => setName(event.target.value)}
/>
<TextAreaField
label={t('settings.servers.templates.templateDescription')}
name="guild_template_description"
rows={2}
maxLength={200}
placeholder={t('settings.servers.templates.descriptionPlaceholder')}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
{create.isError ? <ErrorNotice error={create.error} /> : null}
{saved && !create.isError ? (
<p role="status" className="text-sm text-success">
{t('settings.servers.templates.saved')}
</p>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={create.isPending || full}>
{t(create.isPending ? 'common.saving' : 'settings.servers.templates.save')}
</Button>
</div>
</form>
<Modal
open={removing !== null}
title={t('settings.servers.templates.deleteConfirmTitle')}
onClose={() => setRemoving(null)}
footer={
<>
<Button variant="ghost" onClick={() => setRemoving(null)}>
{t('common.cancel')}
</Button>
<Button
variant="ghost"
className="border-danger/50 text-danger"
disabled={remove.isPending}
onClick={() => {
if (removing !== null) {
remove.mutate(removing.id);
}
}}
>
{t('settings.servers.templates.deleteConfirm')}
</Button>
</>
}
>
<p className="text-sm text-fg-muted">
{t('settings.servers.templates.deleteConfirmBody', {
name: removing === null ? '' : removing.name,
})}
</p>
</Modal>
</SettingsSection>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
bodyOf,
findRequest,
installFetch,
json,
makeUser,
recordedRequests,
renderApp,
resetStores,
type FetchRoute,
} from './helpers';
/**
* Личные шаблоны серверов (AGENT.md 7.3, Фаза 7): модалка создания сервера
* показывает свои шаблоны рядом со встроенными и отправляет `personal:<id>`,
* а секция «Шаблоны сервера» сохраняет структуру и удаляет шаблоны.
*/
const user = makeUser();
const builtinTemplate = {
id: 'empty',
name: 'Пустой',
description: 'Одна общая текстовая комната.',
roles: ['Администратор', 'Пользователь'],
text_channels: 1,
voice_channels: 0,
categories: [],
};
const myTemplate = {
id: 't-1',
name: 'Моя структура',
description: 'Снято с сервера',
roles: ['Администратор', 'Пользователь', 'Модератор'],
text_channels: 2,
voice_channels: 1,
categories: ['Информация'],
created_at: '2026-09-26T10:00:00.000Z',
source_guild_id: 'g-1',
};
const guildSummary = {
id: 'g-1',
name: 'Сервер',
description: '',
owner_id: 'user-9',
is_main: true,
member_count: 1,
roles: [],
my_role_ids: [],
my_permissions: ['MANAGE_GUILD'],
};
/** Маршруты оболочки приложения, каталога шаблонов и своих шаблонов. */
function appRoutes(extra: FetchRoute[] = []): FetchRoute[] {
return [
{ match: '/api/v1/guild-templates/mine', response: () => json({ templates: [myTemplate] }) },
{ match: '/api/v1/guild-templates', response: () => json({ templates: [builtinTemplate] }) },
...extra,
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guildSummary] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [] }) },
{ match: '/api/v1/guilds/g-1', response: () => json({ guild: guildSummary }) },
];
}
/** Открывает модалку создания сервера и дожидается списка шаблонов. */
async function openCreateGuildModal() {
renderApp('/app');
await userEvent.click(await screen.findByLabelText('Создать сервер'));
return screen.findByRole('dialog', { name: 'Создать сервер' });
}
/** Открывает настройки сервера и дожидается секции «Шаблоны сервера». */
async function openTemplatesSection() {
renderApp('/settings/servers/g-1');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
const counter = await within(dialog).findByTestId('guild-templates-counter');
return { dialog, counter };
}
beforeEach(() => {
resetStores();
});
describe('создание сервера по своему шаблону', () => {
it('показывает свои шаблоны и отправляет template_id personal:<id>', async () => {
const fetchMock = installFetch(
appRoutes([
{
match: '/api/v1/guilds',
method: 'POST',
response: () =>
json({
guild: {
id: 'g-new',
name: 'Новый',
owner_id: 'user-1',
is_main: false,
member_count: 1,
my_role_ids: [],
my_permissions: ['ADMINISTRATOR'],
},
}),
},
]),
);
const dialog = await openCreateGuildModal();
const option = await within(dialog).findByTestId('guild-template-personal-t-1');
expect(within(option).getByText('Моя структура')).toBeVisible();
// Счётчики как у встроенных шаблонов: 2 текстовые, 1 голосовая, 3 роли.
expect(within(option).getByText(/2 текст/)).toBeVisible();
await userEvent.click(within(option).getByRole('radio'));
await userEvent.type(within(dialog).getByLabelText('Название сервера'), 'Новый');
await userEvent.click(within(dialog).getByRole('button', { name: 'Создать' }));
await waitFor(() => {
expect(bodyOf(findRequest(fetchMock, { url: '/guilds', method: 'POST' }))).toEqual({
name: 'Новый',
template_id: 'personal:t-1',
});
});
});
it('без своих шаблонов показывает подсказку и встроенные шаблоны', async () => {
installFetch([
{ match: '/api/v1/guild-templates/mine', response: () => json({ templates: [] }) },
{ match: '/api/v1/guild-templates', response: () => json({ templates: [builtinTemplate] }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guildSummary] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
]);
const dialog = await openCreateGuildModal();
expect(await within(dialog).findByTestId('guild-template-empty')).toBeVisible();
expect(within(dialog).getByTestId('my-guild-templates')).toBeVisible();
expect(within(dialog).getByText(/Своих шаблонов пока нет/)).toBeVisible();
});
});
describe('настройки сервера: секция «Шаблоны сервера»', () => {
it('сохраняет структуру сервера и удаляет шаблон', async () => {
const fetchMock = installFetch(
appRoutes([
{
match: '/api/v1/guild-templates',
method: 'POST',
response: () => json({ template: { ...myTemplate, id: 't-2', name: 'Копия' } }),
},
{
match: '/api/v1/guild-templates/t-1',
method: 'DELETE',
response: () => json({ ok: true }),
},
]),
);
const { dialog } = await openTemplatesSection();
expect(await within(dialog).findByTestId('my-guild-template-t-1')).toBeVisible();
// Поля формы «Сохранить структуру как шаблон»: «Описание» есть и у секции
// «Общее», поэтому подпись шаблона отличается, а поиск идёт внутри формы.
const form = within(dialog).getByTestId('guild-templates-form');
await userEvent.type(within(form).getByLabelText('Название шаблона'), 'Копия');
await userEvent.type(within(form).getByLabelText('Описание шаблона'), 'Снимок структуры');
await userEvent.click(within(form).getByRole('button', { name: 'Сохранить шаблон' }));
expect(await within(dialog).findByText('Шаблон сохранён.')).toBeVisible();
const post = recordedRequests(fetchMock).find(
(request) => request.url.includes('/guild-templates') && request.method === 'POST',
);
expect(post?.url).toContain('/api/v1/guild-templates');
expect(post?.body).toEqual({
guild_id: 'g-1',
name: 'Копия',
description: 'Снимок структуры',
});
await userEvent.click(
within(dialog).getByRole('button', { name: 'Удалить шаблон Моя структура' }),
);
const confirm = await screen.findByRole('dialog', { name: 'Удалить шаблон?' });
await userEvent.click(within(confirm).getByRole('button', { name: 'Удалить' }));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/guild-templates/t-1', method: 'DELETE' }),
).toBeDefined();
});
});
it('не показывает секцию участнику без права MANAGE_GUILD', async () => {
const member = { ...guildSummary, owner_id: 'user-9', my_permissions: ['VIEW_GUILD'] };
installFetch([
{ match: '/api/v1/guild-templates/mine', response: () => json({ templates: [myTemplate] }) },
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [member] }) },
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1/channels', response: () => json({ channels: [] }) },
{ match: '/api/v1/guilds/g-1', response: () => json({ guild: member }) },
]);
renderApp('/settings/servers/g-1');
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
// Секция «Общее» отрисовалась — значит страница настроек загрузилась.
expect(await within(dialog).findByLabelText('Название сервера')).toBeVisible();
expect(within(dialog).queryByTestId('guild-templates-counter')).toBeNull();
});
});