feat(templates): add service language template management
Add normalized service and template catalogs, scoped administration, migration, validation, and regression coverage. Ref: IT-1601
This commit is contained in:
@@ -1,172 +1,77 @@
|
||||
@using HrynCo.NotificationService.DAL.Abstract.Templates
|
||||
@model IReadOnlyList<EmailTemplate>
|
||||
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
|
||||
@model ServiceTemplatesViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Email Templates";
|
||||
var serviceNameFilter = ViewData["ServiceNameFilter"] as string ?? string.Empty;
|
||||
var keyFilter = ViewData["KeyFilter"] as string ?? string.Empty;
|
||||
var filterQuery = string.IsNullOrWhiteSpace(serviceNameFilter) && string.IsNullOrWhiteSpace(keyFilter)
|
||||
? string.Empty
|
||||
: $"?serviceNameFilter={Uri.EscapeDataString(serviceNameFilter)}&keyFilter={Uri.EscapeDataString(keyFilter)}";
|
||||
var listQuery = string.IsNullOrWhiteSpace(serviceNameFilter) && string.IsNullOrWhiteSpace(keyFilter)
|
||||
? string.Empty
|
||||
: $"?serviceName={Uri.EscapeDataString(serviceNameFilter)}&key={Uri.EscapeDataString(keyFilter)}";
|
||||
ViewData["Title"] = Model.Service.Name;
|
||||
var serviceId = Model.Service.Id;
|
||||
var keyFilter = Model.KeyFilter;
|
||||
}
|
||||
|
||||
<nav aria-label="Breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li><li class="breadcrumb-item active" aria-current="page">@Model.Service.Name</li></ol></nav>
|
||||
<div class="page-header">
|
||||
<h2><i class="bi bi-envelope-paper"></i> Email Templates</h2>
|
||||
<a href="/admin/templates/create@(filterQuery)" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i> Create New Template
|
||||
</a>
|
||||
<h2>@Model.Service.Name</h2>
|
||||
<a href="/admin/services/@serviceId/edit" class="btn btn-outline-primary">Edit Service</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<form id="templateFiltersForm" method="get" action="/admin/templates" class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-5">
|
||||
<label class="form-label fw-semibold" for="serviceName">Service Name</label>
|
||||
<input id="serviceName"
|
||||
name="serviceName"
|
||||
value="@serviceNameFilter"
|
||||
class="form-control"
|
||||
placeholder="Filter by service name" />
|
||||
</div>
|
||||
<div class="col-12 col-md-5">
|
||||
<label class="form-label fw-semibold" for="key">Key</label>
|
||||
<input id="key"
|
||||
name="key"
|
||||
value="@keyFilter"
|
||||
class="form-control"
|
||||
placeholder="Filter by key" />
|
||||
</div>
|
||||
<div class="col-12 col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary w-100">Filter</button>
|
||||
<a id="clearTemplateFilters" href="/admin/templates" class="btn btn-outline-secondary w-100">Clear</a>
|
||||
<div class="service-template-catalog mb-3" aria-label="Configured service languages">
|
||||
<span class="fw-semibold">Configured languages:</span>
|
||||
@if (Model.Service.Languages.Count == 0)
|
||||
{
|
||||
<span>No languages configured for this service.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var language in Model.Service.Languages)
|
||||
{
|
||||
<span class="service-template-language-code">@language.Code</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<h3 class="h4">Email Templates</h3>
|
||||
<a asp-action="Create" asp-route-serviceId="@serviceId" asp-route-keyFilter="@keyFilter" class="btn btn-primary">Create template</a>
|
||||
</div>
|
||||
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
|
||||
<div class="table-responsive"><table class="table table-hover service-template-grid"><thead><tr>
|
||||
<th scope="col">
|
||||
<form method="get" action="/admin/services/@serviceId" class="template-key-filter">
|
||||
<label for="key" class="form-label fw-semibold">Template Key</label>
|
||||
<div class="template-key-filter-controls">
|
||||
<input id="key" name="key" value="@keyFilter" class="form-control form-control-sm" placeholder="Filter by key" />
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/admin/services/@serviceId" class="btn btn-outline-secondary btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!ViewData.ModelState.IsValid)
|
||||
</th>
|
||||
<th scope="col">Languages</th>
|
||||
<th scope="col" class="template-actions">Action</th>
|
||||
</tr></thead><tbody>
|
||||
@if (Model.Definitions.Count == 0)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
|
||||
{
|
||||
<div>@error.ErrorMessage</div>
|
||||
}
|
||||
</div>
|
||||
<tr><td colspan="3">No email templates found for this service.</td></tr>
|
||||
}
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const storageKey = 'hrynco.notificationService.adminTemplates.filters';
|
||||
const form = document.getElementById('templateFiltersForm');
|
||||
const serviceNameInput = document.getElementById('serviceName');
|
||||
const keyInput = document.getElementById('key');
|
||||
const clearLink = document.getElementById('clearTemplateFilters');
|
||||
|
||||
if (!form || !serviceNameInput || !keyInput || !clearLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saveState = () => {
|
||||
const state = {
|
||||
serviceName: serviceNameInput.value ?? '',
|
||||
key: keyInput.value ?? ''
|
||||
};
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||||
};
|
||||
|
||||
const restoreState = () => {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return false;
|
||||
@foreach (var template in Model.Definitions)
|
||||
{
|
||||
var editUrl = Url.Action("Edit", "AdminTemplates", new { serviceId, definitionId = template.Id, keyFilter });
|
||||
var presentCodes = template.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
|
||||
<tr>
|
||||
<td>@template.Key</td>
|
||||
<td>
|
||||
@if (Model.Service.Languages.Count == 0)
|
||||
{
|
||||
<span>No configured languages</span>
|
||||
}
|
||||
|
||||
try {
|
||||
const state = JSON.parse(raw);
|
||||
const serviceName = typeof state.serviceName === 'string' ? state.serviceName : '';
|
||||
const key = typeof state.key === 'string' ? state.key : '';
|
||||
|
||||
serviceNameInput.value = serviceName;
|
||||
keyInput.value = key;
|
||||
|
||||
return serviceName.length > 0 || key.length > 0;
|
||||
} catch {
|
||||
localStorage.removeItem(storageKey);
|
||||
return false;
|
||||
else
|
||||
{
|
||||
<ul class="template-language-statuses">
|
||||
@foreach (var language in Model.Service.Languages)
|
||||
{
|
||||
var present = presentCodes.Contains(language.Code);
|
||||
<li class="template-language-status @(present ? "template-language-present" : "template-language-missing")" data-language-code="@language.Code" data-state="@(present ? "present" : "missing")">
|
||||
<span>@language.Code:</span><span>@(present ? "Present" : "Missing")</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener('submit', saveState);
|
||||
clearLink.addEventListener('click', () => localStorage.removeItem(storageKey));
|
||||
|
||||
const hasQueryParams = new URLSearchParams(window.location.search).toString().length > 0;
|
||||
if (!hasQueryParams && restoreState()) {
|
||||
form.requestSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
saveState();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@if (Model is null || Model.Count == 0)
|
||||
{
|
||||
<div class="card shadow-sm table-card">
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-envelope-paper"></i>
|
||||
<p class="mt-2 mb-0">No email templates found.</p>
|
||||
<a href="/admin/templates/create" class="btn btn-primary btn-sm mt-3">
|
||||
<i class="bi bi-plus-lg me-1"></i> Create First Template
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card shadow-sm table-card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Service Name</th>
|
||||
<th>Key</th>
|
||||
<th>Language</th>
|
||||
<th>Subject</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var t in Model)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.ServiceName</td>
|
||||
<td>@t.Key</td>
|
||||
<td>@t.LanguageCode</td>
|
||||
<td>@t.Subject</td>
|
||||
<td class="text-end">
|
||||
<a href="/admin/templates/@t.ServiceName/@t.Key/@t.LanguageCode@(filterQuery)"
|
||||
class="btn btn-sm btn-outline-primary me-1">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
<form method="post"
|
||||
action="/admin/templates/@t.ServiceName/@t.Key/@t.LanguageCode/delete"
|
||||
class="d-inline">
|
||||
<input type="hidden" name="serviceNameFilter" value="@serviceNameFilter" />
|
||||
<input type="hidden" name="keyFilter" value="@keyFilter" />
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Delete this template?')">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="template-actions"><a href="@editUrl" class="btn btn-outline-primary btn-sm" aria-label="Edit template @template.Key">Edit</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody></table></div>
|
||||
|
||||
Reference in New Issue
Block a user