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:
@@ -0,0 +1,13 @@
|
||||
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
|
||||
@model ServiceLanguageDeleteViewModel
|
||||
@{ ViewData["Title"] = "Delete service language and versions"; }
|
||||
<h2>Delete language @Model.Code from @Model.ServiceName?</h2>
|
||||
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
|
||||
<div class="alert alert-danger">This permanently deletes <strong>@Model.VersionCount template version(s)</strong> using this language in this service and removes the language code. Template definitions, their keys and shared variables are retained.</div>
|
||||
<form method="post" action="/admin/services/@Model.ServiceId/languages/@Model.LanguageId/delete">
|
||||
@Html.AntiForgeryToken()
|
||||
<input asp-for="VersionCount" type="hidden" /><input asp-for="Revision" type="hidden" />
|
||||
<div class="form-check mb-3"><input asp-for="ConfirmDelete" class="form-check-input" /><label asp-for="ConfirmDelete" class="form-check-label">I confirm deletion of @Model.VersionCount version(s) and language @Model.Code.</label></div>
|
||||
<button type="submit" class="btn btn-danger">Delete @Model.VersionCount version(s) and language</button>
|
||||
<a class="btn btn-secondary" href="/admin/services/@Model.ServiceId/edit">Cancel</a>
|
||||
</form>
|
||||
@@ -0,0 +1,48 @@
|
||||
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
|
||||
@model NotificationServiceEditViewModel
|
||||
@{
|
||||
Layout = "~/Views/Shared/_EditorLayout.cshtml";
|
||||
ViewData["Title"] = Model.IsNew ? "Create Service" : "Edit Service";
|
||||
ViewData["EditorTitle"] = ViewData["Title"];
|
||||
}
|
||||
@section Breadcrumbs {
|
||||
<nav aria-label="Breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li>@if (!Model.IsNew) { <li class="breadcrumb-item"><a href="/admin/services/@Model.Id">@Model.Name</a></li> }<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li></ol></nav>
|
||||
}
|
||||
<form asp-action="Save" id="serviceForm" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input asp-for="Id" type="hidden" /><input asp-for="Revision" type="hidden" />
|
||||
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
|
||||
<label asp-for="Name" class="form-label fw-semibold">Service name</label>
|
||||
<input asp-for="Name" class="form-control mb-3" maxlength="100" />
|
||||
<h3 class="h5">Language codes</h3>
|
||||
<p class="text-muted">Codes are case-sensitive and belong to this service. Existing template versions use these codes when sending. Renaming a service or code changes sender lookup; align publishers and email channel service names.</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table service-language-table"><thead><tr><th>Code</th><th>Versions</th><th class="service-language-actions">Actions</th></tr></thead><tbody id="serviceLanguages">
|
||||
@for (var i = 0; i < Model.Languages.Count; i++)
|
||||
{
|
||||
var countUnavailable = ViewData["LanguageCountsUnavailable"] is true && Model.Languages[i].Id != Guid.Empty;
|
||||
<tr>
|
||||
<td><input asp-for="Languages[i].Id" type="hidden" /><label for="service-code-@i" class="visually-hidden">Language code</label><input asp-for="Languages[i].Code" id="service-code-@i" class="form-control" maxlength="10" /></td>
|
||||
<td>@(countUnavailable ? "Unavailable" : Model.Languages[i].VersionCount.ToString())</td>
|
||||
<td class="service-language-actions">
|
||||
<div class="service-language-action-buttons">
|
||||
<button type="button" class="btn btn-outline-secondary remove-service-language" data-count="@Model.Languages[i].VersionCount" disabled="@countUnavailable">Remove</button>
|
||||
@if (!Model.IsNew && Model.Languages[i].Id != Guid.Empty)
|
||||
{
|
||||
<a class="btn btn-outline-danger" href="/admin/services/@Model.Id/languages/@Model.Languages[i].Id/delete">Delete language and versions…</a>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody></table>
|
||||
</div>
|
||||
<button type="button" id="addServiceLanguage" class="btn btn-outline-primary">Add language code</button>
|
||||
<div id="serviceLanguageAlert" role="alert" class="text-danger mt-3"></div>
|
||||
</form>
|
||||
<template id="serviceLanguageRow"><tr><td><input type="hidden" name="Languages[__index__].Id" value="00000000-0000-0000-0000-000000000000" /><label for="service-code-__index__" class="visually-hidden">Language code</label><input id="service-code-__index__" name="Languages[__index__].Code" class="form-control" maxlength="10" /></td><td>0</td><td class="service-language-actions"><div class="service-language-action-buttons"><button type="button" class="btn btn-outline-secondary remove-service-language" data-count="0">Remove</button></div></td></tr></template>
|
||||
@section FormActions {
|
||||
<button type="submit" form="serviceForm" class="btn btn-primary">Save service</button>
|
||||
<a href="@(Model.IsNew ? "/admin/services" : $"/admin/services/{Model.Id}")" class="btn btn-secondary">Cancel</a>
|
||||
}
|
||||
@section Scripts { <script src="~/js/service-editor.js" asp-append-version="true"></script> }
|
||||
@@ -0,0 +1,14 @@
|
||||
@using HrynCo.NotificationService.DAL.Abstract.Templates
|
||||
@model IReadOnlyList<NotificationServiceInfo>
|
||||
@{ ViewData["Title"] = "Services"; }
|
||||
<div class="page-header"><h2>Services</h2><a href="/admin/services/create" class="btn btn-primary">Create service</a></div>
|
||||
@if (Model.Count == 0) { <p>No services yet. Create a service and its language codes before adding templates.</p> }
|
||||
else
|
||||
{
|
||||
<div class="table-responsive"><table class="table table-hover"><thead><tr><th>Name</th><th>Language codes</th><th class="service-actions">Action</th></tr></thead><tbody>
|
||||
@foreach (var service in Model)
|
||||
{
|
||||
<tr><td><a href="/admin/services/@service.Id">@service.Name</a></td><td>@string.Join(", ", service.Languages.Select(l => l.Code))</td><td class="service-actions"><a href="/admin/services/@service.Id/edit" class="btn btn-outline-primary btn-sm" aria-label="Edit service @service.Name">Edit</a></td></tr>
|
||||
}
|
||||
</tbody></table></div>
|
||||
}
|
||||
@@ -1,300 +1,102 @@
|
||||
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
|
||||
|
||||
@model EmailTemplateEditViewModel
|
||||
@{
|
||||
Layout = "~/Views/Shared/_EditorLayout.cshtml";
|
||||
ViewData["Title"] = Model.PageTitle;
|
||||
ViewData["EditorTitle"] = Model.PageTitle;
|
||||
}
|
||||
|
||||
<form id="templateForm" asp-action="Save" asp-controller="AdminTemplates" method="post">
|
||||
var active = Math.Clamp(Model.ActiveLanguageIndex, 0, Math.Max(0, Model.Languages.Count - 1));
|
||||
}
|
||||
@section Breadcrumbs {
|
||||
<nav aria-label="Breadcrumb" class="template-editor-breadcrumbs"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li><li class="breadcrumb-item"><a href="/admin/services/@Model.ServiceId">@Model.ServiceName</a></li><li class="breadcrumb-item active" aria-current="page">@Model.PageTitle</li></ol></nav>
|
||||
}
|
||||
<form id="templateForm" asp-action="Save" asp-controller="AdminTemplates" asp-route-serviceId="@Model.ServiceId" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input asp-for="Id" type="hidden" />
|
||||
<input type="hidden" name="IsNew" value="@Model.IsNew" />
|
||||
<input asp-for="ServiceNameFilter" type="hidden" />
|
||||
<input asp-for="Revision" type="hidden" />
|
||||
<input asp-for="ServiceId" type="hidden" /><input asp-for="ServiceName" type="hidden" />
|
||||
<input asp-for="KeyFilter" type="hidden" />
|
||||
|
||||
@if (!ViewData.ModelState.IsValid)
|
||||
{
|
||||
<div class="alert alert-danger mb-3">
|
||||
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
|
||||
<input type="hidden" id="ActiveLanguageIndex" name="ActiveLanguageIndex" value="@active" />
|
||||
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<dl class="template-service-context">
|
||||
<dt>Service</dt>
|
||||
<dd>@Model.ServiceName</dd>
|
||||
</dl>
|
||||
<div class="form-text"><a href="/admin/services/@Model.ServiceId/edit" class="template-service-edit-link">Edit Service and language codes</a></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Key" class="form-label fw-semibold">Template Key</label>
|
||||
<input asp-for="Key" class="form-control" readonly="@(!Model.IsNew)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="VariablesJson" class="form-label fw-semibold">Shared variables (JSON)</label>
|
||||
<textarea asp-for="VariablesJson" class="form-control font-monospace" rows="4"></textarea>
|
||||
<div class="form-text">One contract for every language: <code>[{"name":"UserName","required":true}]</code></div>
|
||||
</div>
|
||||
<p class="text-muted">Every configured service language has a tab. Leave all three content fields blank for an unused language; clear them to remove an existing version. Shared variables and the template remain.</p>
|
||||
@if (Model.Languages.Count == 0) { <p>No service languages configured. You can save the key and shared variables now, then add language codes in Edit Service.</p> }
|
||||
<div class="template-language-sheet mb-3" hidden="@(Model.Languages.Count == 0)">
|
||||
<div class="nav nav-tabs" role="tablist" aria-label="Template languages" id="languageTabs">
|
||||
@for (var i = 0; i < Model.Languages.Count; i++)
|
||||
{
|
||||
<div>@error.ErrorMessage</div>
|
||||
<button type="button" role="tab" id="language-tab-@i" aria-controls="language-pane-@i"
|
||||
aria-selected="@(i == active ? "true" : "false")" tabindex="@(i == active ? 0 : -1)"
|
||||
class="nav-link @(i == active ? "active" : "")" data-pane="language-pane-@i">@Model.Languages[i].LanguageCode</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul class="nav nav-tabs template-editor-tabs mb-3" id="templateEditorTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="edit-tab" data-bs-toggle="tab" data-bs-target="#edit-pane" type="button" role="tab" aria-controls="edit-pane" aria-selected="true">
|
||||
Edit
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="preview-tab" data-bs-toggle="tab" data-bs-target="#preview-pane" type="button" role="tab" aria-controls="preview-pane" aria-selected="false">
|
||||
Preview
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="edit-pane" role="tabpanel" aria-labelledby="edit-tab" tabindex="0">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label asp-for="ServiceName" class="form-label fw-semibold">Service Name</label>
|
||||
<input asp-for="ServiceName" class="form-control" readonly="@(!Model.IsNew)" />
|
||||
<span asp-validation-for="ServiceName" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label asp-for="Key" class="form-label fw-semibold">Key</label>
|
||||
<input asp-for="Key" class="form-control" readonly="@(!Model.IsNew)" />
|
||||
<span asp-validation-for="Key" class="text-danger small"></span>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="LanguageCode" class="form-label fw-semibold">Language</label>
|
||||
<input asp-for="LanguageCode" class="form-control" readonly="@(!Model.IsNew)" />
|
||||
<span asp-validation-for="LanguageCode" class="text-danger small"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Subject" class="form-label fw-semibold">Subject</label>
|
||||
<input asp-for="Subject" class="form-control" />
|
||||
<span asp-validation-for="Subject" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="HtmlBody" class="form-label fw-semibold">HTML Body</label>
|
||||
<textarea asp-for="HtmlBody" class="form-control font-monospace" rows="10"></textarea>
|
||||
<span asp-validation-for="HtmlBody" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="TextBody" class="form-label fw-semibold">Text Body</label>
|
||||
<textarea asp-for="TextBody" class="form-control font-monospace" rows="5"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="VariablesJson" class="form-label fw-semibold">Variables (JSON)</label>
|
||||
<textarea asp-for="VariablesJson" class="form-control font-monospace" rows="4"
|
||||
placeholder='[{"name":"UserName","required":true}]'></textarea>
|
||||
<span asp-validation-for="VariablesJson" class="text-danger small"></span>
|
||||
<div class="form-text">JSON array of <code>{"name":"...", "required":true|false}</code></div>
|
||||
</div>
|
||||
<div id="languagePanes">
|
||||
@for (var i = 0; i < Model.Languages.Count; i++)
|
||||
{
|
||||
<section class="language-pane" id="language-pane-@i" role="tabpanel" aria-labelledby="language-tab-@i" hidden="@(i != active)">
|
||||
<input asp-for="Languages[i].LanguageCode" type="hidden" class="language-code" />
|
||||
<div class="mb-3">
|
||||
<label asp-for="Languages[i].Subject" class="form-label fw-semibold">Subject</label>
|
||||
<input asp-for="Languages[i].Subject" class="form-control" data-content="subject" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Languages[i].HtmlBody" class="form-label fw-semibold">HTML Body</label>
|
||||
<textarea asp-for="Languages[i].HtmlBody" class="form-control font-monospace" rows="10" data-content="html"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label asp-for="Languages[i].TextBody" class="form-label fw-semibold">Text Body</label>
|
||||
<textarea asp-for="Languages[i].TextBody" class="form-control font-monospace" rows="5" data-content="text"></textarea>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="preview-pane" role="tabpanel" aria-labelledby="preview-tab" tabindex="0">
|
||||
<div class="template-preview-panel mb-3">
|
||||
<div class="template-preview-panel-header">
|
||||
<div>
|
||||
<div class="template-preview-title">Preview</div>
|
||||
<div class="template-preview-subtitle">Rendered with sample values from the variable list.</div>
|
||||
</div>
|
||||
<span id="previewStatus" class="badge text-bg-secondary">Ready</span>
|
||||
</div>
|
||||
|
||||
<div class="template-preview-body">
|
||||
<div class="template-preview-source template-preview-section-block">
|
||||
<div class="template-preview-section-title">Sample values</div>
|
||||
<div id="previewVariables" class="template-preview-variables"></div>
|
||||
<div class="form-text mt-2">Change these values to see the rendered output update immediately.</div>
|
||||
</div>
|
||||
|
||||
<div class="template-preview-output">
|
||||
<div class="template-preview-section-title">Rendered subject</div>
|
||||
<div id="previewSubject" class="template-preview-subject"></div>
|
||||
|
||||
<div class="template-preview-section-title mt-3">Rendered HTML</div>
|
||||
<iframe id="previewHtmlFrame" class="template-preview-frame" title="Email HTML preview"></iframe>
|
||||
|
||||
<div class="template-preview-section-title mt-3">Rendered text</div>
|
||||
<pre id="previewText" class="template-preview-text mb-0"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-preview-panel mb-3">
|
||||
<div class="template-preview-panel-header">
|
||||
<div>
|
||||
<div class="template-preview-title">Selected language preview: <span id="previewLanguage"></span></div>
|
||||
<div class="template-preview-subtitle">Sample values use the shared contract. External content is blocked in this preview.</div>
|
||||
</div>
|
||||
<span id="previewStatus" class="badge text-bg-secondary" role="status">Ready</span>
|
||||
</div>
|
||||
<div class="template-preview-body">
|
||||
<div class="template-preview-source template-preview-section-block">
|
||||
<div class="template-preview-section-title">Sample values</div>
|
||||
<div id="previewVariables" class="template-preview-variables"></div>
|
||||
</div>
|
||||
<div class="template-preview-output">
|
||||
<div class="template-preview-section-title">Rendered subject</div>
|
||||
<div id="previewSubject" class="template-preview-subject"></div>
|
||||
<div class="template-preview-section-title mt-3">Rendered HTML</div>
|
||||
<iframe id="previewHtmlFrame" class="template-preview-frame" sandbox="" referrerpolicy="no-referrer" title="Email HTML preview"></iframe>
|
||||
<div class="template-preview-section-title mt-3">Rendered text</div>
|
||||
<pre id="previewText" class="template-preview-text mb-0"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section FormActions {
|
||||
<button type="submit" form="templateForm" class="btn btn-primary">
|
||||
<i class="bi bi-floppy me-1"></i> Save
|
||||
</button>
|
||||
<a href="/admin/templates@(string.IsNullOrWhiteSpace(Model.ServiceNameFilter) && string.IsNullOrWhiteSpace(Model.KeyFilter) ? string.Empty : $"?serviceName={Uri.EscapeDataString(Model.ServiceNameFilter ?? string.Empty)}&key={Uri.EscapeDataString(Model.KeyFilter ?? string.Empty)}")" class="btn btn-secondary">
|
||||
<i class="bi bi-x-lg me-1"></i> Cancel
|
||||
</a>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
(function () {
|
||||
const subjectField = document.getElementById('Subject');
|
||||
const htmlField = document.getElementById('HtmlBody');
|
||||
const textField = document.getElementById('TextBody');
|
||||
const variablesField = document.getElementById('VariablesJson');
|
||||
const previewVariablesHost = document.getElementById('previewVariables');
|
||||
const previewSubject = document.getElementById('previewSubject');
|
||||
const previewText = document.getElementById('previewText');
|
||||
const previewFrame = document.getElementById('previewHtmlFrame');
|
||||
const previewStatus = document.getElementById('previewStatus');
|
||||
const previewTab = document.getElementById('preview-tab');
|
||||
|
||||
if (!subjectField || !htmlField || !textField || !variablesField || !previewVariablesHost || !previewSubject || !previewText || !previewFrame || !previewStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function parseVariables() {
|
||||
const raw = variablesField.value?.trim() || '[]';
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('Variables JSON must be an array.');
|
||||
}
|
||||
|
||||
return parsed
|
||||
.filter(item => item && typeof item.Name === 'string' && item.Name.trim().length > 0)
|
||||
.map(item => ({
|
||||
name: item.Name.trim(),
|
||||
required: !!item.Required
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSampleValue(name) {
|
||||
return `Sample ${name}`;
|
||||
}
|
||||
|
||||
function renderVariableInputs() {
|
||||
let variables = [];
|
||||
|
||||
try {
|
||||
variables = parseVariables();
|
||||
previewStatus.className = 'badge text-bg-secondary';
|
||||
previewStatus.textContent = 'Ready';
|
||||
} catch (error) {
|
||||
previewVariablesHost.innerHTML = `<div class="alert alert-warning mb-0">${escapeHtml(error.message || 'Invalid variables JSON')}</div>`;
|
||||
previewStatus.className = 'badge text-bg-danger';
|
||||
previewStatus.textContent = 'Invalid JSON';
|
||||
updatePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
if (variables.length === 0) {
|
||||
previewVariablesHost.innerHTML = '<div class="text-muted small">No variables defined.</div>';
|
||||
updatePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValues = readVariableValues();
|
||||
previewVariablesHost.innerHTML = variables.map(variable => {
|
||||
const value = Object.prototype.hasOwnProperty.call(currentValues, variable.name)
|
||||
? currentValues[variable.name]
|
||||
: buildSampleValue(variable.name);
|
||||
return `
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">${escapeHtml(variable.name)}${variable.required ? ' *' : ''}</label>
|
||||
<input type="text" class="form-control form-control-sm preview-variable-input" data-variable-name="${escapeHtml(variable.name)}" value="${escapeHtml(value)}" />
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
previewVariablesHost.querySelectorAll('.preview-variable-input').forEach(input => {
|
||||
input.addEventListener('input', updatePreview);
|
||||
});
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function readVariableValues() {
|
||||
const values = {};
|
||||
previewVariablesHost.querySelectorAll('.preview-variable-input').forEach(input => {
|
||||
values[input.dataset.variableName] = input.value;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
function interpolate(text, values) {
|
||||
let result = text || '';
|
||||
|
||||
Object.keys(values).forEach(key => {
|
||||
const token = new RegExp(`\\{\\{${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}\\}`, 'g');
|
||||
result = result.replace(token, values[key] ?? '');
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
let values = {};
|
||||
|
||||
try {
|
||||
values = readVariableValues();
|
||||
} catch (error) {
|
||||
values = {};
|
||||
}
|
||||
|
||||
const renderedSubject = interpolate(subjectField.value, values);
|
||||
const renderedHtml = interpolate(htmlField.value, values);
|
||||
const renderedText = interpolate(textField.value, values);
|
||||
|
||||
previewSubject.textContent = renderedSubject || '(empty subject)';
|
||||
previewText.textContent = renderedText || '(empty text body)';
|
||||
previewFrame.srcdoc = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: #eef2f7;
|
||||
color: #1f2937;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.email-shell {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, .08);
|
||||
}
|
||||
.email-body {
|
||||
padding: 28px 36px;
|
||||
}
|
||||
img { max-width: 100%; height: auto; }
|
||||
a { color: #2563eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-shell">
|
||||
<div class="email-body">
|
||||
${renderedHtml || '<div style="color:#6b7280">No HTML body provided.</div>'}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
variablesField.addEventListener('input', renderVariableInputs);
|
||||
subjectField.addEventListener('input', updatePreview);
|
||||
htmlField.addEventListener('input', updatePreview);
|
||||
textField.addEventListener('input', updatePreview);
|
||||
if (previewTab) {
|
||||
previewTab.addEventListener('shown.bs.tab', updatePreview);
|
||||
}
|
||||
|
||||
renderVariableInputs();
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
</form>
|
||||
@section FormActions {
|
||||
<button type="submit" form="templateForm" class="btn btn-primary"><i class="bi bi-floppy me-1"></i> Save</button>
|
||||
<a id="cancelTemplate" href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })" class="btn btn-secondary">Cancel</a>
|
||||
}
|
||||
@section Scripts {
|
||||
<script src="~/js/email-template-editor.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||
}
|
||||
<div class="editor-wrapper">
|
||||
@RenderSection("Breadcrumbs", required: false)
|
||||
<div class="card editor-card">
|
||||
<div class="card-header editor-card-header-bar">
|
||||
<i class="bi bi-pencil-square me-2"></i>@ViewData["EditorTitle"]
|
||||
<h1 class="editor-card-title"><i class="bi bi-pencil-square" aria-hidden="true"></i><span>@ViewData["EditorTitle"]</span></h1>
|
||||
</div>
|
||||
<div class="card-body editor-card-body">
|
||||
@RenderBody()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@{
|
||||
var currentController = ViewContext.RouteData.Values["controller"]?.ToString() ?? "";
|
||||
bool isTemplates = currentController.Equals("AdminTemplates", StringComparison.OrdinalIgnoreCase);
|
||||
bool isServices = currentController.Equals("AdminServices", StringComparison.OrdinalIgnoreCase)
|
||||
|| currentController.Equals("AdminTemplates", StringComparison.OrdinalIgnoreCase);
|
||||
bool isChannels = currentController.Equals("AdminChannels", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
@@ -26,11 +27,7 @@
|
||||
<nav class="col-md-2 d-none d-md-block sidebar py-3">
|
||||
<div class="sidebar-section-label">Management</div>
|
||||
<ul class="nav flex-column px-2">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(isTemplates ? "active" : "")" href="/admin/templates">
|
||||
<i class="bi bi-envelope-paper"></i> Email Templates
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item"><a class="nav-link @(isServices ? "active" : "")" href="/admin/services"><i class="bi bi-collection"></i> Services</a></li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link @(isChannels ? "active" : "")" href="/admin/channels">
|
||||
<i class="bi bi-broadcast"></i> Email Channels
|
||||
|
||||
Reference in New Issue
Block a user