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:
+43
-1
@@ -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', {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Забаненные не могут вернуться по приглашению, пока бан не снят.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user