Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5082dd4fcf | |||
| cfbda4ca05 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.8.0
|
VERSION: 1.8.2
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.8.0</Version>
|
<Version>1.8.2</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v1.8.0`.
|
**Текущая версия:** `v1.8.2`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
|
- **🔐 Авторизация через Telegram**: Безопасный вход с использованием Telegram Login Widget (HMAC-SHA256 валидация).
|
||||||
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
|
- **📝 Удобное редактирование**: Веб-интерфейс для детального редактирования сессий, изменения дат, названий и статусов.
|
||||||
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
|
- **🤝 Co-GM и делегирование**: Owner группы назначает помощников по Telegram ID, а co-GM получает доступ к управлению расписанием в Telegram и Web Dashboard.
|
||||||
- **📋 Шаблоны кампаний**: Owner и co-GM сохраняют типовые параметры кампании и создают новый повторяющийся batch из шаблона за один шаг.
|
- **📋 Шаблоны кампаний**: Owner и co-GM управляют типовыми параметрами кампаний в отдельной вкладке `Шаблоны`, а на странице группы запускают новый повторяющийся batch из выбранного шаблона.
|
||||||
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
|
- **🧩 Bulk-операции для Batch Sessions**: ГМ может обновить общий title/link, перенести всю пачку на фиксированный шаг и клонировать batch на следующую неделю или месяц.
|
||||||
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
|
- **🔕 Режим уведомлений batch**: Для каждой пачки можно выбрать `В группе и в личку` или `Только в группе`.
|
||||||
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
|
- **⬆️ Управление очередью**: Веб-интерфейс показывает заполненность, лист ожидания и позволяет ГМу поднять первого игрока из очереди.
|
||||||
@@ -157,9 +157,10 @@ Owner или co-GM нажимает кнопку `⏰ Перенести` у н
|
|||||||
|
|
||||||
Дедлайн должен быть в будущем и раньше первого предложенного времени. Участники выбирают один вариант кнопкой в Telegram, могут изменить голос до дедлайна и видят текущие результаты в сообщении голосования. По дедлайну бот выбирает вариант с наибольшим числом голосов, переносит сессию, сбрасывает RSVP и обновляет batch-сообщение. Если голосов нет или есть ничья, перенос отклоняется, а время сессии остаётся прежним.
|
Дедлайн должен быть в будущем и раньше первого предложенного времени. Участники выбирают один вариант кнопкой в Telegram, могут изменить голос до дедлайна и видят текущие результаты в сообщении голосования. По дедлайну бот выбирает вариант с наибольшим числом голосов, переносит сессию, сбрасывает RSVP и обновляет batch-сообщение. Если голосов нет или есть ничья, перенос отклоняется, а время сессии остаётся прежним.
|
||||||
|
|
||||||
### Bulk-операции в Web Dashboard
|
### Шаблоны и bulk-операции в Web Dashboard
|
||||||
На странице группы Web Dashboard показывает отдельный блок для каждой пачки игр. Owner и co-GM могут:
|
Вкладка `Шаблоны` в левом меню вынесена отдельно от страницы группы. Owner и co-GM выбирают группу, сохраняют шаблон кампании с названием, ссылкой, количеством игр, интервалом, лимитом мест и режимом уведомлений, а также удаляют устаревшие шаблоны.
|
||||||
- сохранить шаблон кампании с названием, ссылкой, количеством игр, интервалом, лимитом мест и режимом уведомлений;
|
|
||||||
|
На странице группы Web Dashboard показывает только применение сохранённых шаблонов и отдельный блок для каждой пачки игр. Owner и co-GM могут:
|
||||||
- создать новый batch из шаблона, выбрав только первую дату расписания;
|
- создать новый batch из шаблона, выбрав только первую дату расписания;
|
||||||
- обновить общий `title` и `link` сразу у всех сессий batch;
|
- обновить общий `title` и `link` сразу у всех сессий batch;
|
||||||
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
|
- выбрать режим уведомлений: дублировать важные сообщения игрокам в личку или оставить только групповые уведомления;
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.8.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.8.2
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
- gmrelay
|
- gmrelay
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.8.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:1.8.2
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Панель управления
|
Панель управления
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink class="nav-item" href="templates" @onclick="CloseMenu">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
||||||
|
<path d="M7 8h10"/>
|
||||||
|
<path d="M7 12h6"/>
|
||||||
|
<path d="M7 16h8"/>
|
||||||
|
</svg>
|
||||||
|
Шаблоны
|
||||||
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-footer">
|
<div class="nav-footer">
|
||||||
@@ -47,7 +56,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v1.3.0</div>
|
<div class="nav-version">v1.8.2</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
|
|||||||
@@ -56,13 +56,17 @@
|
|||||||
.nav-section {
|
.nav-section {
|
||||||
padding: 0 0.75rem;
|
padding: 0 0.75rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Nav Items === */
|
/* === Nav Items === */
|
||||||
.nav-item {
|
.nav-section ::deep .nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
padding: 0.625rem 0.875rem;
|
padding: 0.625rem 0.875rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -70,16 +74,16 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all var(--transition-normal);
|
transition: all var(--transition-normal);
|
||||||
margin-bottom: 0.125rem;
|
white-space: nowrap;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-section ::deep .nav-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active,
|
.nav-section ::deep .nav-item.active {
|
||||||
.nav-item ::deep a.active {
|
|
||||||
background: rgba(124, 58, 237, 0.15);
|
background: rgba(124, 58, 237, 0.15);
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
border: 1px solid rgba(124, 58, 237, 0.2);
|
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
@page "/templates"
|
||||||
|
@using GmRelay.Web.Services
|
||||||
|
@using GmRelay.Shared.Domain
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@attribute [Authorize]
|
||||||
|
@inject AuthorizedSessionService SessionService
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<PageTitle>Шаблоны кампаний — GM-Relay</PageTitle>
|
||||||
|
|
||||||
|
<div class="page-container">
|
||||||
|
<ul class="gm-breadcrumb animate-fade-in">
|
||||||
|
<li><a href="/">Главная</a></li>
|
||||||
|
<li class="active">Шаблоны кампаний</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="page-header animate-fade-in">
|
||||||
|
<h2>📋 Шаблоны кампаний</h2>
|
||||||
|
<p>Сохраняйте типовые кампании один раз и применяйте их на странице нужной группы.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
|
{
|
||||||
|
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||||
|
⚠️ @errorMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(successMessage))
|
||||||
|
{
|
||||||
|
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
|
||||||
|
✅ @successMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (groups is null)
|
||||||
|
{
|
||||||
|
<div class="glass-card" style="padding: 2rem;">
|
||||||
|
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 1rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 0.75rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 70%;"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (groups.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="glass-card">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">🤖</div>
|
||||||
|
<div class="empty-state-title">Нет доступных групп</div>
|
||||||
|
<p class="empty-state-text">Добавьте бота GM-Relay в группу Telegram, чтобы создать первый шаблон кампании.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||||
|
<div class="batch-bulk-header">
|
||||||
|
<div>
|
||||||
|
<h3>Группа для шаблонов</h3>
|
||||||
|
<p>@(SelectedGroup?.Name ?? "Выберите группу")</p>
|
||||||
|
</div>
|
||||||
|
@if (SelectedGroup is not null)
|
||||||
|
{
|
||||||
|
<span class="status-badge @(SelectedGroup.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
|
||||||
|
@FormatRole(SelectedGroup.ManagerRole)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="template-group-selector">
|
||||||
|
<select value="@selectedGroupId" @onchange="OnSelectedGroupChanged" class="gm-form-control">
|
||||||
|
@foreach (var group in groups)
|
||||||
|
{
|
||||||
|
<option value="@group.Id">@group.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (SelectedGroup is not null)
|
||||||
|
{
|
||||||
|
<a href="/group/@SelectedGroup.Id" class="btn-gm btn-gm-outline">Открыть группу →</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||||
|
<div class="batch-bulk-header">
|
||||||
|
<div>
|
||||||
|
<h3>Новый шаблон</h3>
|
||||||
|
<p>Эти параметры будут использоваться при запуске batch из группы.</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge status-info">Template</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditForm Model="@templateModel" OnValidSubmit="CreateCampaignTemplate">
|
||||||
|
<div class="campaign-template-fields">
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Название шаблона</label>
|
||||||
|
<InputText @bind-Value="templateModel.Name" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Название кампании</label>
|
||||||
|
<InputText @bind-Value="templateModel.Title" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Ссылка</label>
|
||||||
|
<InputText @bind-Value="templateModel.JoinLink" class="gm-form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Игр</label>
|
||||||
|
<InputNumber @bind-Value="templateModel.SessionCount" class="gm-form-control" min="1" max="52" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Интервал, дней</label>
|
||||||
|
<InputNumber @bind-Value="templateModel.IntervalDays" class="gm-form-control" min="1" max="365" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Мест</label>
|
||||||
|
<InputNumber @bind-Value="templateModel.MaxPlayers" class="gm-form-control" min="1" />
|
||||||
|
</div>
|
||||||
|
<div class="gm-form-group">
|
||||||
|
<label class="gm-form-label">Уведомления</label>
|
||||||
|
<select @bind="templateModel.NotificationMode" class="gm-form-control">
|
||||||
|
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
|
||||||
|
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isCreatingTemplate">
|
||||||
|
@(isCreatingTemplate ? "⏳ Сохраняем..." : "💾 Сохранить шаблон")
|
||||||
|
</button>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||||
|
<div class="batch-bulk-header">
|
||||||
|
<div>
|
||||||
|
<h3>Сохранённые шаблоны</h3>
|
||||||
|
<p>@campaignTemplateModels.Count для выбранной группы</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge status-info">@campaignTemplateModels.Count</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (campaignTemplates is null)
|
||||||
|
{
|
||||||
|
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 55%;"></div>
|
||||||
|
}
|
||||||
|
else if (campaignTemplateModels.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="empty-state empty-state-compact">
|
||||||
|
<div class="empty-state-title">Шаблонов пока нет</div>
|
||||||
|
<p class="empty-state-text">Создайте первый шаблон для выбранной группы.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="campaign-template-list">
|
||||||
|
@foreach (var template in campaignTemplateModels)
|
||||||
|
{
|
||||||
|
<div class="campaign-template-row template-management-row">
|
||||||
|
<div class="campaign-template-info">
|
||||||
|
<h3>@template.Name</h3>
|
||||||
|
<p>@FormatTemplateSummary(template)</p>
|
||||||
|
</div>
|
||||||
|
<div class="template-management-actions">
|
||||||
|
<span class="status-badge status-neutral">@FormatLocalMoscow(template.UpdatedAt.ToMoscow())</span>
|
||||||
|
<button type="button" class="btn-gm btn-gm-danger" disabled="@(deletingTemplateId == template.Id)" @onclick="() => DeleteCampaignTemplate(template)">
|
||||||
|
@(deletingTemplateId == template.Id ? "⏳ Удаляем..." : "Удалить")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<WebGameGroup>? groups;
|
||||||
|
private List<WebCampaignTemplate>? campaignTemplates;
|
||||||
|
private List<CampaignTemplateManagementModel> campaignTemplateModels = [];
|
||||||
|
private Guid selectedGroupId;
|
||||||
|
private Guid? deletingTemplateId;
|
||||||
|
private bool isCreatingTemplate;
|
||||||
|
private long telegramId;
|
||||||
|
private string? errorMessage;
|
||||||
|
private string? successMessage;
|
||||||
|
private CampaignTemplateEditModel templateModel = new();
|
||||||
|
|
||||||
|
private WebGameGroup? SelectedGroup => groups?.FirstOrDefault(group => group.Id == selectedGroupId);
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
if (!authState.User.TryGetTelegramId(out telegramId))
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
||||||
|
selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty;
|
||||||
|
|
||||||
|
if (selectedGroupId != Guid.Empty)
|
||||||
|
{
|
||||||
|
await LoadTemplates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSelectedGroupChanged(ChangeEventArgs args)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(args.Value?.ToString(), out var groupId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedGroupId = groupId;
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
await LoadTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadTemplates()
|
||||||
|
{
|
||||||
|
campaignTemplates = null;
|
||||||
|
campaignTemplateModels = [];
|
||||||
|
|
||||||
|
var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId);
|
||||||
|
if (templates is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
campaignTemplates = templates;
|
||||||
|
RebuildCampaignTemplateModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateCampaignTemplate()
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
|
||||||
|
if (selectedGroupId == Guid.Empty)
|
||||||
|
{
|
||||||
|
errorMessage = "Выберите группу для шаблона.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ValidateCampaignTemplate(templateModel))
|
||||||
|
{
|
||||||
|
errorMessage = "Шаблон должен иметь название, ссылку, 1-52 игр, шаг 1-365 дней и положительный лимит мест, если он указан.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreatingTemplate = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SessionService.CreateCampaignTemplateForGmAsync(
|
||||||
|
selectedGroupId,
|
||||||
|
telegramId,
|
||||||
|
new CreateCampaignTemplateRequest(
|
||||||
|
templateModel.Name,
|
||||||
|
templateModel.Title,
|
||||||
|
templateModel.JoinLink,
|
||||||
|
templateModel.SessionCount,
|
||||||
|
templateModel.IntervalDays,
|
||||||
|
templateModel.MaxPlayers,
|
||||||
|
SessionNotificationModeExtensions.FromDatabaseValue(templateModel.NotificationMode)));
|
||||||
|
|
||||||
|
templateModel = new();
|
||||||
|
successMessage = "Шаблон кампании сохранён.";
|
||||||
|
await LoadTemplates();
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось сохранить шаблон: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isCreatingTemplate = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteCampaignTemplate(CampaignTemplateManagementModel template)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
deletingTemplateId = template.Id;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
|
||||||
|
successMessage = "Шаблон кампании удалён.";
|
||||||
|
await LoadTemplates();
|
||||||
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = "Не удалось удалить шаблон: " + ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
deletingTemplateId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildCampaignTemplateModels()
|
||||||
|
{
|
||||||
|
campaignTemplateModels = campaignTemplates?
|
||||||
|
.OrderByDescending(template => template.UpdatedAt)
|
||||||
|
.ThenBy(template => template.Name)
|
||||||
|
.Select(template => new CampaignTemplateManagementModel
|
||||||
|
{
|
||||||
|
Id = template.Id,
|
||||||
|
Name = template.Name,
|
||||||
|
Title = template.Title,
|
||||||
|
JoinLink = template.JoinLink,
|
||||||
|
SessionCount = template.SessionCount,
|
||||||
|
IntervalDays = template.IntervalDays,
|
||||||
|
MaxPlayers = template.MaxPlayers,
|
||||||
|
NotificationMode = template.NotificationMode,
|
||||||
|
UpdatedAt = template.UpdatedAt
|
||||||
|
})
|
||||||
|
.ToList() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ValidateCampaignTemplate(CampaignTemplateEditModel template)
|
||||||
|
{
|
||||||
|
template.Name = template.Name.Trim();
|
||||||
|
template.Title = template.Title.Trim();
|
||||||
|
template.JoinLink = template.JoinLink.Trim();
|
||||||
|
|
||||||
|
if (template.MaxPlayers.HasValue && template.MaxPlayers.Value <= 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.Name.Length > 0 &&
|
||||||
|
template.Title.Length > 0 &&
|
||||||
|
template.JoinLink.Length > 0 &&
|
||||||
|
template.SessionCount is >= 1 and <= 52 &&
|
||||||
|
template.IntervalDays is >= 1 and <= 365;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatTemplateSummary(CampaignTemplateManagementModel template)
|
||||||
|
{
|
||||||
|
var seats = template.MaxPlayers.HasValue
|
||||||
|
? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест"
|
||||||
|
: "без лимита";
|
||||||
|
|
||||||
|
return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatNotificationMode(string notificationMode) =>
|
||||||
|
SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch
|
||||||
|
{
|
||||||
|
SessionNotificationMode.GroupOnly => "только группа",
|
||||||
|
_ => "группа и личка"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string FormatRole(string role) =>
|
||||||
|
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
|
||||||
|
|
||||||
|
private static string FormatLocalMoscow(DateTime localMoscow) =>
|
||||||
|
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
|
||||||
|
|
||||||
|
private sealed class CampaignTemplateEditModel
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string JoinLink { get; set; } = "";
|
||||||
|
public int SessionCount { get; set; } = 6;
|
||||||
|
public int IntervalDays { get; set; } = 7;
|
||||||
|
public int? MaxPlayers { get; set; }
|
||||||
|
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CampaignTemplateManagementModel
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string Name { get; init; } = "";
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
public string JoinLink { get; init; } = "";
|
||||||
|
public int SessionCount { get; init; }
|
||||||
|
public int IntervalDays { get; init; }
|
||||||
|
public int? MaxPlayers { get; init; }
|
||||||
|
public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue;
|
||||||
|
public DateTime UpdatedAt { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,53 +90,20 @@
|
|||||||
<div class="glass-card campaign-template-panel animate-slide-up">
|
<div class="glass-card campaign-template-panel animate-slide-up">
|
||||||
<div class="batch-bulk-header">
|
<div class="batch-bulk-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>Шаблоны кампаний</h3>
|
<h3>Применить шаблон</h3>
|
||||||
<p>@campaignTemplateModels.Count сохранённых</p>
|
<p>@campaignTemplateModels.Count доступных для этой группы</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-badge status-info">Template</span>
|
<a href="/templates" class="btn-gm btn-gm-outline">⚙️ Управлять шаблонами</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditForm Model="@campaignTemplateModel" OnValidSubmit="CreateCampaignTemplate">
|
@if (campaignTemplateModels.Count == 0)
|
||||||
<div class="campaign-template-fields">
|
{
|
||||||
<div class="gm-form-group">
|
<div class="empty-state empty-state-compact">
|
||||||
<label class="gm-form-label">Название шаблона</label>
|
<div class="empty-state-title">Шаблонов пока нет</div>
|
||||||
<InputText @bind-Value="campaignTemplateModel.Name" class="gm-form-control" />
|
<p class="empty-state-text">Создайте шаблоны в отдельной вкладке, а здесь запускайте из них новые batch-расписания.</p>
|
||||||
</div>
|
|
||||||
<div class="gm-form-group">
|
|
||||||
<label class="gm-form-label">Название кампании</label>
|
|
||||||
<InputText @bind-Value="campaignTemplateModel.Title" class="gm-form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="gm-form-group">
|
|
||||||
<label class="gm-form-label">Ссылка</label>
|
|
||||||
<InputText @bind-Value="campaignTemplateModel.JoinLink" class="gm-form-control" />
|
|
||||||
</div>
|
|
||||||
<div class="gm-form-group">
|
|
||||||
<label class="gm-form-label">Игр</label>
|
|
||||||
<InputNumber @bind-Value="campaignTemplateModel.SessionCount" class="gm-form-control" min="1" max="52" />
|
|
||||||
</div>
|
|
||||||
<div class="gm-form-group">
|
|
||||||
<label class="gm-form-label">Интервал, дней</label>
|
|
||||||
<InputNumber @bind-Value="campaignTemplateModel.IntervalDays" class="gm-form-control" min="1" max="365" />
|
|
||||||
</div>
|
|
||||||
<div class="gm-form-group">
|
|
||||||
<label class="gm-form-label">Мест</label>
|
|
||||||
<InputNumber @bind-Value="campaignTemplateModel.MaxPlayers" class="gm-form-control" min="1" />
|
|
||||||
</div>
|
|
||||||
<div class="gm-form-group">
|
|
||||||
<label class="gm-form-label">Уведомления</label>
|
|
||||||
<select @bind="campaignTemplateModel.NotificationMode" class="gm-form-control">
|
|
||||||
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
|
|
||||||
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isCreatingTemplate">
|
else
|
||||||
@(isCreatingTemplate ? "⏳ Сохраняем..." : "💾 Сохранить шаблон")
|
|
||||||
</button>
|
|
||||||
</EditForm>
|
|
||||||
|
|
||||||
@if (campaignTemplateModels.Count > 0)
|
|
||||||
{
|
{
|
||||||
<div class="campaign-template-list">
|
<div class="campaign-template-list">
|
||||||
@foreach (var template in campaignTemplateModels)
|
@foreach (var template in campaignTemplateModels)
|
||||||
@@ -151,9 +118,6 @@
|
|||||||
<button type="button" class="btn-gm btn-gm-success" disabled="@IsTemplateBusy(template)" @onclick="() => CreateBatchFromTemplate(template)">
|
<button type="button" class="btn-gm btn-gm-success" disabled="@IsTemplateBusy(template)" @onclick="() => CreateBatchFromTemplate(template)">
|
||||||
@(processingTemplateId == template.Id ? "⏳ Создаём..." : "📅 Создать batch")
|
@(processingTemplateId == template.Id ? "⏳ Создаём..." : "📅 Создать batch")
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-gm btn-gm-danger" disabled="@IsTemplateBusy(template)" @onclick="() => DeleteCampaignTemplate(template)">
|
|
||||||
@(deletingTemplateId == template.Id ? "⏳ Удаляем..." : "Удалить")
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -347,15 +311,12 @@
|
|||||||
private Guid? promotingSessionId;
|
private Guid? promotingSessionId;
|
||||||
private Guid? processingBatchId;
|
private Guid? processingBatchId;
|
||||||
private Guid? processingTemplateId;
|
private Guid? processingTemplateId;
|
||||||
private Guid? deletingTemplateId;
|
|
||||||
private long? removingCoGmId;
|
private long? removingCoGmId;
|
||||||
private bool isAddingCoGm;
|
private bool isAddingCoGm;
|
||||||
private bool isCreatingTemplate;
|
|
||||||
private long telegramId;
|
private long telegramId;
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
private string? successMessage;
|
private string? successMessage;
|
||||||
private CoGmEditModel coGmModel = new();
|
private CoGmEditModel coGmModel = new();
|
||||||
private CampaignTemplateEditModel campaignTemplateModel = new();
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -588,51 +549,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CreateCampaignTemplate()
|
|
||||||
{
|
|
||||||
errorMessage = null;
|
|
||||||
successMessage = null;
|
|
||||||
|
|
||||||
if (!ValidateCampaignTemplate(campaignTemplateModel))
|
|
||||||
{
|
|
||||||
errorMessage = "Шаблон должен иметь название, ссылку, 1-52 игр, шаг 1-365 дней и положительный лимит мест, если он указан.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isCreatingTemplate = true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await SessionService.CreateCampaignTemplateForGmAsync(
|
|
||||||
GroupId,
|
|
||||||
telegramId,
|
|
||||||
new CreateCampaignTemplateRequest(
|
|
||||||
campaignTemplateModel.Name,
|
|
||||||
campaignTemplateModel.Title,
|
|
||||||
campaignTemplateModel.JoinLink,
|
|
||||||
campaignTemplateModel.SessionCount,
|
|
||||||
campaignTemplateModel.IntervalDays,
|
|
||||||
campaignTemplateModel.MaxPlayers,
|
|
||||||
SessionNotificationModeExtensions.FromDatabaseValue(campaignTemplateModel.NotificationMode)));
|
|
||||||
|
|
||||||
campaignTemplateModel = new();
|
|
||||||
successMessage = "Шаблон кампании сохранён.";
|
|
||||||
await LoadSessions();
|
|
||||||
}
|
|
||||||
catch (SessionAccessDeniedException)
|
|
||||||
{
|
|
||||||
Navigation.NavigateTo("/access-denied");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
errorMessage = "Не удалось сохранить шаблон: " + ex.Message;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
isCreatingTemplate = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CreateBatchFromTemplate(CampaignTemplateUsageModel template)
|
private async Task CreateBatchFromTemplate(CampaignTemplateUsageModel template)
|
||||||
{
|
{
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
@@ -666,32 +582,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteCampaignTemplate(CampaignTemplateUsageModel template)
|
|
||||||
{
|
|
||||||
errorMessage = null;
|
|
||||||
successMessage = null;
|
|
||||||
deletingTemplateId = template.Id;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
|
|
||||||
successMessage = "Шаблон кампании удалён.";
|
|
||||||
await LoadSessions();
|
|
||||||
}
|
|
||||||
catch (SessionAccessDeniedException)
|
|
||||||
{
|
|
||||||
Navigation.NavigateTo("/access-denied");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
errorMessage = "Не удалось удалить шаблон: " + ex.Message;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
deletingTemplateId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RebuildBatchModels()
|
private void RebuildBatchModels()
|
||||||
{
|
{
|
||||||
batchModels = sessions?
|
batchModels = sessions?
|
||||||
@@ -746,28 +636,9 @@
|
|||||||
return batch.Title.Length > 0 && batch.JoinLink.Length > 0;
|
return batch.Title.Length > 0 && batch.JoinLink.Length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ValidateCampaignTemplate(CampaignTemplateEditModel template)
|
|
||||||
{
|
|
||||||
template.Name = template.Name.Trim();
|
|
||||||
template.Title = template.Title.Trim();
|
|
||||||
template.JoinLink = template.JoinLink.Trim();
|
|
||||||
|
|
||||||
if (template.MaxPlayers.HasValue && template.MaxPlayers.Value <= 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return template.Name.Length > 0 &&
|
|
||||||
template.Title.Length > 0 &&
|
|
||||||
template.JoinLink.Length > 0 &&
|
|
||||||
template.SessionCount is >= 1 and <= 52 &&
|
|
||||||
template.IntervalDays is >= 1 and <= 365;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
|
||||||
|
|
||||||
private bool IsTemplateBusy(CampaignTemplateUsageModel template) =>
|
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
|
||||||
processingTemplateId == template.Id || deletingTemplateId == template.Id;
|
|
||||||
|
|
||||||
private string CurrentUserRole =>
|
private string CurrentUserRole =>
|
||||||
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
|
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
|
||||||
@@ -864,17 +735,6 @@
|
|||||||
public string CloneInterval { get; set; } = "week";
|
public string CloneInterval { get; set; } = "week";
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class CampaignTemplateEditModel
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = "";
|
|
||||||
public string Title { get; set; } = "";
|
|
||||||
public string JoinLink { get; set; } = "";
|
|
||||||
public int SessionCount { get; set; } = 6;
|
|
||||||
public int IntervalDays { get; set; } = 7;
|
|
||||||
public int? MaxPlayers { get; set; }
|
|
||||||
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class CampaignTemplateUsageModel
|
private sealed class CampaignTemplateUsageModel
|
||||||
{
|
{
|
||||||
public Guid Id { get; init; }
|
public Guid Id { get; init; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
GM-Relay Design System v1.8.0
|
GM-Relay Design System v1.8.2
|
||||||
Dark RPG Dashboard Theme
|
Dark RPG Dashboard Theme
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@@ -662,11 +662,34 @@ select option {
|
|||||||
|
|
||||||
.campaign-template-actions {
|
.campaign-template-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(190px, 1fr) auto auto;
|
grid-template-columns: minmax(190px, 1fr) auto;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-group-selector {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-management-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-management-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-compact {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Animations === */
|
/* === Animations === */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
@@ -890,7 +913,8 @@ select option {
|
|||||||
.batch-clone-row,
|
.batch-clone-row,
|
||||||
.campaign-template-fields,
|
.campaign-template-fields,
|
||||||
.campaign-template-row,
|
.campaign-template-row,
|
||||||
.campaign-template-actions {
|
.campaign-template-actions,
|
||||||
|
.template-group-selector {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class CampaignTemplatesNavigationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task NavMenu_ShouldExposeTemplatesTab()
|
||||||
|
{
|
||||||
|
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
||||||
|
|
||||||
|
Assert.Contains("href=\"templates\"", navMenu, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Шаблоны", navMenu, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NavMenuStyles_ShouldStyleNavLinkAnchorsAsStackedRows()
|
||||||
|
{
|
||||||
|
var navCss = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor.css"));
|
||||||
|
|
||||||
|
Assert.Contains("::deep .nav-item", navCss, StringComparison.Ordinal);
|
||||||
|
Assert.Matches(
|
||||||
|
@"\.nav-section\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*gap:\s*0\.25rem;",
|
||||||
|
navCss);
|
||||||
|
Assert.Matches(
|
||||||
|
@"::deep\s+\.nav-item\s*\{[^}]*display:\s*flex;[^}]*width:\s*100%;",
|
||||||
|
navCss);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GroupDetails_ShouldApplyTemplatesWithoutManagingThem()
|
||||||
|
{
|
||||||
|
var groupDetails = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/GroupDetails.razor"));
|
||||||
|
|
||||||
|
Assert.Contains("CreateBatchFromTemplate", groupDetails, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("OnValidSubmit=\"CreateCampaignTemplate\"", groupDetails, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("DeleteCampaignTemplate", groupDetails, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CampaignTemplatesPage_ShouldOwnTemplateManagement()
|
||||||
|
{
|
||||||
|
var templatesPage = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Pages/CampaignTemplates.razor"));
|
||||||
|
|
||||||
|
Assert.Contains("@page \"/templates\"", templatesPage, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("OnValidSubmit=\"CreateCampaignTemplate\"", templatesPage, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("DeleteCampaignTemplate", templatesPage, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepositoryFile(string relativePath)
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user