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,125 @@
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin;
|
||||
|
||||
[Route("admin/services")]
|
||||
public sealed class AdminServicesController(INotificationServiceRepository services, ILogger<AdminServicesController> logger) : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Index(CancellationToken ct) => View(await services.GetAllAsync(ct));
|
||||
|
||||
[HttpGet("create")]
|
||||
public IActionResult Create() => View("Edit", new NotificationServiceEditViewModel());
|
||||
|
||||
[HttpGet("{id:guid}/edit")]
|
||||
public async Task<IActionResult> Edit([FromRoute] Guid id, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(id, ct);
|
||||
return service is null ? NotFound() : View(ToViewModel(service));
|
||||
}
|
||||
|
||||
[HttpPost("save")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Save(NotificationServiceEditViewModel model, CancellationToken ct)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return await ServiceEditorViewAsync(model, ct);
|
||||
try
|
||||
{
|
||||
var service = new NotificationServiceInfo
|
||||
{
|
||||
Id = model.Id ?? Guid.Empty, Name = model.Name, Revision = model.Revision, Languages = model.Languages
|
||||
};
|
||||
await services.SaveAsync(service, model.IsNew, ct);
|
||||
return RedirectToAction(nameof(AdminTemplatesController.Index), "AdminTemplates", new { serviceId = service.Id });
|
||||
}
|
||||
catch (EmailTemplateWriteException error)
|
||||
{
|
||||
ModelState.AddModelError("", error.Message);
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(error, "Failed to save notification service.");
|
||||
ModelState.AddModelError("", "Could not save the service. Your entered values are retained; try again.");
|
||||
}
|
||||
return await ServiceEditorViewAsync(model, ct);
|
||||
}
|
||||
|
||||
[HttpGet("{serviceId:guid}/languages/{languageId:guid}/delete")]
|
||||
public async Task<IActionResult> ConfirmLanguageRemoval([FromRoute] Guid serviceId, [FromRoute] Guid languageId, CancellationToken ct)
|
||||
{
|
||||
var model = await RemovalModelAsync(serviceId, languageId, ct);
|
||||
return model is null ? NotFound() : View("DeleteLanguage", model);
|
||||
}
|
||||
|
||||
[HttpPost("{serviceId:guid}/languages/{languageId:guid}/delete")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> RemoveLanguage([FromRoute] Guid serviceId, [FromRoute] Guid languageId, ServiceLanguageDeleteViewModel model, CancellationToken ct)
|
||||
{
|
||||
if (!ModelState.IsValid || !model.ConfirmDelete)
|
||||
return await RemovalErrorAsync(serviceId, languageId, "Confirm deletion of the language and all listed versions to continue.", ct);
|
||||
try
|
||||
{
|
||||
await services.RemoveLanguageAsync(serviceId, languageId, model.VersionCount, model.Revision, ct);
|
||||
return RedirectToAction(nameof(Edit), new { id = serviceId });
|
||||
}
|
||||
catch (EmailTemplateWriteException error)
|
||||
{
|
||||
return await RemovalErrorAsync(serviceId, languageId, error.Message, ct);
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(error, "Failed to remove service language.");
|
||||
return await RemovalErrorAsync(serviceId, languageId, "Could not delete the language. No changes were saved; review the count and try again.", ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ServiceEditorViewAsync(NotificationServiceEditViewModel model, CancellationToken ct)
|
||||
{
|
||||
if (model.Id is not Guid id)
|
||||
return View("Edit", model);
|
||||
try
|
||||
{
|
||||
var persisted = await services.GetAsync(id, ct);
|
||||
var counts = persisted?.Languages.ToDictionary(l => l.Id, l => l.VersionCount) ?? [];
|
||||
foreach (var language in model.Languages)
|
||||
language.VersionCount = counts.GetValueOrDefault(language.Id);
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(error, "Failed to refresh service language version counts.");
|
||||
ViewData["LanguageCountsUnavailable"] = true;
|
||||
ModelState.AddModelError("", "Could not refresh language usage counts. Removal is disabled until the service is available.");
|
||||
}
|
||||
return View("Edit", model);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> RemovalErrorAsync(Guid serviceId, Guid languageId, string error, CancellationToken ct)
|
||||
{
|
||||
var model = await RemovalModelAsync(serviceId, languageId, ct);
|
||||
if (model is null)
|
||||
return NotFound();
|
||||
ModelState.Clear();
|
||||
ModelState.AddModelError("", error);
|
||||
return View("DeleteLanguage", model);
|
||||
}
|
||||
|
||||
private async Task<ServiceLanguageDeleteViewModel?> RemovalModelAsync(Guid serviceId, Guid languageId, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
var language = service?.Languages.SingleOrDefault(l => l.Id == languageId);
|
||||
return language is null ? null : new ServiceLanguageDeleteViewModel
|
||||
{
|
||||
ServiceId = serviceId, LanguageId = languageId, ServiceName = service!.Name, Revision = service.Revision,
|
||||
Code = language.Code, VersionCount = language.VersionCount
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationServiceEditViewModel ToViewModel(NotificationServiceInfo service) => new()
|
||||
{
|
||||
Id = service.Id, Name = service.Name, Revision = service.Revision, Languages = service.Languages.ToList()
|
||||
};
|
||||
}
|
||||
@@ -1,159 +1,178 @@
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Create;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Delete;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Get;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.GetAll;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Update;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.SaveDefinition;
|
||||
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin;
|
||||
|
||||
[Route("admin/templates")]
|
||||
public class AdminTemplatesController : Controller
|
||||
[Route("admin/services/{serviceId:guid}/templates")]
|
||||
public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateDefinitionRepository definitions,
|
||||
ILogger<AdminTemplatesController> logger, INotificationServiceRepository services) : Controller
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
|
||||
public AdminTemplatesController(IMediator mediator)
|
||||
[HttpGet("/admin/services/{serviceId:guid}")]
|
||||
public async Task<IActionResult> Index([FromRoute] Guid serviceId, [FromQuery] string? key, CancellationToken ct)
|
||||
{
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
// GET /admin/templates
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Index([FromQuery] string? serviceName, [FromQuery] string? key, CancellationToken ct)
|
||||
{
|
||||
ViewData["ServiceNameFilter"] = serviceName;
|
||||
ViewData["KeyFilter"] = key;
|
||||
|
||||
var result = await _mediator.Send(new GetAllEmailTemplatesQuery(serviceName, key), ct);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to load templates.");
|
||||
return View(Array.Empty<EmailTemplate>());
|
||||
}
|
||||
|
||||
return View(result.Result);
|
||||
}
|
||||
|
||||
// GET /admin/templates/create
|
||||
[HttpGet("create")]
|
||||
public IActionResult Create([FromQuery] string? serviceNameFilter, [FromQuery] string? keyFilter)
|
||||
{
|
||||
return View("Edit", new EmailTemplateEditViewModel
|
||||
{
|
||||
ServiceNameFilter = serviceNameFilter,
|
||||
KeyFilter = keyFilter
|
||||
});
|
||||
}
|
||||
|
||||
// GET /admin/templates/{serviceName}/{key}/{languageCode}
|
||||
[HttpGet("{serviceName}/{key}/{languageCode}")]
|
||||
public async Task<IActionResult> Edit(
|
||||
string serviceName,
|
||||
string key,
|
||||
string languageCode,
|
||||
[FromQuery] string? serviceNameFilter,
|
||||
[FromQuery] string? keyFilter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _mediator.Send(new GetEmailTemplateQuery(serviceName, key, languageCode), ct);
|
||||
if (!result.IsSuccess || result.Result is null)
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
if (service is null)
|
||||
return NotFound();
|
||||
|
||||
var template = result.Result;
|
||||
var vm = new EmailTemplateEditViewModel
|
||||
{
|
||||
Id = template.Id,
|
||||
ServiceName = template.ServiceName,
|
||||
Key = template.Key,
|
||||
LanguageCode = template.LanguageCode,
|
||||
Subject = template.Subject,
|
||||
HtmlBody = template.HtmlBody,
|
||||
TextBody = template.TextBody,
|
||||
VariablesJson = JsonSerializer.Serialize(template.Variables),
|
||||
ServiceNameFilter = serviceNameFilter,
|
||||
KeyFilter = keyFilter
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
// POST /admin/templates/save
|
||||
[HttpPost("save")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Save(EmailTemplateEditViewModel model, CancellationToken ct)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return View("Edit", model);
|
||||
|
||||
List<EmailTemplateVariable> variables;
|
||||
var model = new ServiceTemplatesViewModel { Service = service, KeyFilter = key };
|
||||
if (TempData["TemplateError"] is string previousError)
|
||||
ModelState.AddModelError("", previousError);
|
||||
try
|
||||
{
|
||||
variables = JsonSerializer.Deserialize<List<EmailTemplateVariable>>(model.VariablesJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? [];
|
||||
model.Definitions = (await definitions.GetDefinitionsAsync(service.Name, key, ct))
|
||||
.Where(d => d.ServiceId == serviceId).ToList();
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(error, "Failed to load service template definitions.");
|
||||
ModelState.AddModelError("", "Could not load templates. Try again when the service is available.");
|
||||
}
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpGet("create")]
|
||||
public async Task<IActionResult> Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
return service is null ? NotFound() : EditorView(new EmailTemplateEditViewModel
|
||||
{
|
||||
ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter
|
||||
}, service);
|
||||
}
|
||||
|
||||
[HttpGet("{definitionId:guid}")]
|
||||
public async Task<IActionResult> Edit([FromRoute] Guid serviceId, [FromRoute] Guid definitionId, [FromQuery] string? languageCode,
|
||||
[FromQuery] string? keyFilter, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
var definition = await OwnedDefinitionAsync(service, definitionId, ct);
|
||||
if (definition is null)
|
||||
return NotFound();
|
||||
var model = ToViewModel(definition);
|
||||
model.KeyFilter = keyFilter;
|
||||
model.ActiveLanguageIndex = Math.Max(0, model.Languages.FindIndex(l => l.LanguageCode == languageCode));
|
||||
return EditorView(model, service!);
|
||||
}
|
||||
|
||||
[HttpPost("save")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Save([FromRoute] Guid serviceId, EmailTemplateEditViewModel model, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
if (service is null || model.ServiceId != serviceId || model.ServiceName != service.Name)
|
||||
return NotFound();
|
||||
if (model.Id is Guid definitionId && await OwnedDefinitionAsync(service, definitionId, ct) is null)
|
||||
return NotFound();
|
||||
ValidateLanguageCatalog(model, service);
|
||||
if (!ModelState.IsValid)
|
||||
return EditorView(model, service);
|
||||
var definition = ParseDefinition(model);
|
||||
if (definition is null)
|
||||
return EditorView(model, service);
|
||||
return await SaveDefinitionAsync(definition, model, service, ct);
|
||||
}
|
||||
|
||||
[HttpPost("{definitionId:guid}/languages/{languageCode}/delete")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete([FromRoute] Guid serviceId, [FromRoute] Guid definitionId, [FromRoute] string languageCode,
|
||||
[FromForm] string? keyFilter, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
var definition = await OwnedDefinitionAsync(service, definitionId, ct);
|
||||
var language = definition?.Languages.SingleOrDefault(l => l.LanguageCode == languageCode);
|
||||
if (language is null)
|
||||
return NotFound();
|
||||
var result = await mediator.Send(new DeleteEmailTemplateCommand(service!.Name, definition!.Key, languageCode, language.Id), ct);
|
||||
if (!result.IsSuccess)
|
||||
TempData["TemplateError"] = result.Error?.Message ?? "Failed to delete language version.";
|
||||
return RedirectToAction(nameof(Index), new { serviceId, key = keyFilter });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> SaveDefinitionAsync(EmailTemplateDefinition definition, EmailTemplateEditViewModel model,
|
||||
NotificationServiceInfo service, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await mediator.Send(new SaveEmailTemplateDefinitionCommand(definition, model.IsNew), ct);
|
||||
if (result.IsSuccess)
|
||||
return RedirectToAction(nameof(Index), new { serviceId = service.Id, key = model.KeyFilter });
|
||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to save template.");
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(error, "Failed to save email template definition.");
|
||||
ModelState.AddModelError("", "Could not save the template. Your entered values are retained; try again when the service is available.");
|
||||
}
|
||||
return EditorView(model, service);
|
||||
}
|
||||
|
||||
private async Task<EmailTemplateDefinition?> OwnedDefinitionAsync(NotificationServiceInfo? service, Guid definitionId, CancellationToken ct)
|
||||
{
|
||||
if (service is null)
|
||||
return null;
|
||||
return (await definitions.GetDefinitionsAsync(service.Name, ct: ct))
|
||||
.SingleOrDefault(d => d.Id == definitionId && d.ServiceId == service.Id);
|
||||
}
|
||||
|
||||
private IActionResult EditorView(EmailTemplateEditViewModel model, NotificationServiceInfo service)
|
||||
{
|
||||
foreach (var language in service.Languages)
|
||||
if (!model.Languages.Any(l => l.LanguageCode == language.Code))
|
||||
model.Languages.Add(new EmailTemplateLanguageEditViewModel { LanguageCode = language.Code });
|
||||
return View("Edit", model);
|
||||
}
|
||||
|
||||
private void ValidateLanguageCatalog(EmailTemplateEditViewModel model, NotificationServiceInfo service)
|
||||
{
|
||||
var codes = model.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
|
||||
if (codes.Count != model.Languages.Count)
|
||||
ModelState.AddModelError("", "Language codes must be unique within a template.");
|
||||
if (!codes.SetEquals(service.Languages.Select(l => l.Code)))
|
||||
ModelState.AddModelError("", "The service language catalog changed or the submitted tabs are invalid. Reload before saving.");
|
||||
}
|
||||
|
||||
private EmailTemplateDefinition? ParseDefinition(EmailTemplateEditViewModel model)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new EmailTemplateDefinition
|
||||
{
|
||||
Id = model.Id ?? Guid.NewGuid(), ServiceId = model.ServiceId, Revision = model.Revision,
|
||||
ServiceName = model.ServiceName, Key = model.Key,
|
||||
Variables = EmailTemplateValidation.ParseVariables(model.VariablesJson),
|
||||
Languages = model.Languages.Where(l => l.HasContent).Select(l => new EmailTemplate
|
||||
{
|
||||
ServiceName = model.ServiceName, Key = model.Key, LanguageCode = l.LanguageCode,
|
||||
Subject = l.Subject, HtmlBody = l.HtmlBody, TextBody = l.TextBody, Created = DateTimeOffset.UtcNow
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.VariablesJson), "Invalid JSON format for variables.");
|
||||
return View("Edit", model);
|
||||
ModelState.AddModelError(nameof(model.VariablesJson), "Invalid variables JSON. Use an array of objects with name and required properties.");
|
||||
}
|
||||
|
||||
if (model.IsNew)
|
||||
catch (EmailTemplateWriteException error)
|
||||
{
|
||||
var command = new CreateEmailTemplateCommand(
|
||||
model.ServiceName,
|
||||
model.Key,
|
||||
model.LanguageCode,
|
||||
model.Subject,
|
||||
model.HtmlBody,
|
||||
model.TextBody,
|
||||
variables);
|
||||
|
||||
var result = await _mediator.Send(command, ct);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to create template.");
|
||||
return View("Edit", model);
|
||||
}
|
||||
ModelState.AddModelError(nameof(model.VariablesJson), error.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
var command = new UpdateEmailTemplateCommand(
|
||||
model.ServiceName,
|
||||
model.Key,
|
||||
model.LanguageCode,
|
||||
model.Subject,
|
||||
model.HtmlBody,
|
||||
model.TextBody,
|
||||
variables);
|
||||
|
||||
var result = await _mediator.Send(command, ct);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to update template.");
|
||||
return View("Edit", model);
|
||||
}
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index), new { serviceName = model.ServiceNameFilter, key = model.KeyFilter });
|
||||
return null;
|
||||
}
|
||||
|
||||
// POST /admin/templates/{serviceName}/{key}/{languageCode}/delete
|
||||
[HttpPost("{serviceName}/{key}/{languageCode}/delete")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(
|
||||
string serviceName,
|
||||
string key,
|
||||
string languageCode,
|
||||
[FromForm] string? serviceNameFilter,
|
||||
[FromForm] string? keyFilter,
|
||||
CancellationToken ct)
|
||||
private static EmailTemplateEditViewModel ToViewModel(EmailTemplateDefinition definition) => new()
|
||||
{
|
||||
await _mediator.Send(new DeleteEmailTemplateCommand(serviceName, key, languageCode), ct);
|
||||
return RedirectToAction(nameof(Index), new { serviceName = serviceNameFilter, key = keyFilter });
|
||||
}
|
||||
Id = definition.Id, ServiceId = definition.ServiceId, Revision = definition.Revision,
|
||||
ServiceName = definition.ServiceName, Key = definition.Key,
|
||||
VariablesJson = JsonSerializer.Serialize(definition.Variables, JsonOptions),
|
||||
Languages = definition.Languages.Select(l => new EmailTemplateLanguageEditViewModel
|
||||
{
|
||||
LanguageCode = l.LanguageCode, Subject = l.Subject, HtmlBody = l.HtmlBody, TextBody = l.TextBody
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin;
|
||||
|
||||
[Route("admin/templates")]
|
||||
public sealed class LegacyAdminTemplatesController(INotificationServiceRepository services,
|
||||
IEmailTemplateDefinitionRepository definitions) : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Index([FromQuery] string? serviceName, [FromQuery] string? serviceNameFilter,
|
||||
[FromQuery] string? key, [FromQuery] string? keyFilter, CancellationToken ct)
|
||||
{
|
||||
var name = serviceName ?? serviceNameFilter;
|
||||
var service = (await services.GetAllAsync(ct)).SingleOrDefault(s => s.Name == name);
|
||||
if (service is null)
|
||||
return RedirectToAction("Index", "AdminServices");
|
||||
return RedirectToAction("Index", "AdminTemplates", new { serviceId = service.Id, key = key ?? keyFilter });
|
||||
}
|
||||
|
||||
[HttpGet("create")]
|
||||
public async Task<IActionResult> Create([FromQuery] string? serviceNameFilter, [FromQuery] string? keyFilter, CancellationToken ct)
|
||||
{
|
||||
var service = (await services.GetAllAsync(ct)).SingleOrDefault(s => s.Name == serviceNameFilter);
|
||||
return service is null ? RedirectToAction("Index", "AdminServices")
|
||||
: RedirectToAction("Create", "AdminTemplates", new { serviceId = service.Id, keyFilter });
|
||||
}
|
||||
|
||||
[HttpGet("{serviceName}/{key}")]
|
||||
[HttpGet("{serviceName}/{key}/{languageCode}")]
|
||||
public async Task<IActionResult> Edit(string serviceName, string key, string? languageCode,
|
||||
[FromQuery] string? keyFilter, CancellationToken ct)
|
||||
{
|
||||
var definition = await definitions.GetDefinitionAsync(serviceName, key, ct);
|
||||
return definition is null ? NotFound() : RedirectToAction("Edit", "AdminTemplates",
|
||||
new { serviceId = definition.ServiceId, definitionId = definition.Id, languageCode, keyFilter });
|
||||
}
|
||||
}
|
||||
+7
-19
@@ -2,32 +2,20 @@ using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
|
||||
public class EmailTemplateEditViewModel
|
||||
public sealed class EmailTemplateEditViewModel
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public Guid ServiceId { get; set; }
|
||||
public Guid Revision { get; set; }
|
||||
[Required, StringLength(100)]
|
||||
public string ServiceName { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[Required, StringLength(100)]
|
||||
public string Key { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
public string LanguageCode { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
public string HtmlBody { get; set; } = "";
|
||||
|
||||
public string TextBody { get; set; } = "";
|
||||
|
||||
// JSON array: [{"name":"UserName","required":true}, ...]
|
||||
public string VariablesJson { get; set; } = "[]";
|
||||
public string? ServiceNameFilter { get; set; }
|
||||
public List<EmailTemplateLanguageEditViewModel> Languages { get; set; } = [];
|
||||
public int ActiveLanguageIndex { get; set; }
|
||||
public string? KeyFilter { get; set; }
|
||||
|
||||
public bool IsNew => Id == null;
|
||||
public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template";
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
|
||||
public sealed class EmailTemplateLanguageEditViewModel : IValidatableObject
|
||||
{
|
||||
[Required, StringLength(10)]
|
||||
public string LanguageCode { get; set; } = "";
|
||||
[DisplayFormat(ConvertEmptyStringToNull = false)]
|
||||
public string Subject { get; set; } = "";
|
||||
[DisplayFormat(ConvertEmptyStringToNull = false)]
|
||||
public string HtmlBody { get; set; } = "";
|
||||
[DisplayFormat(ConvertEmptyStringToNull = false)]
|
||||
public string TextBody { get; set; } = "";
|
||||
public bool HasContent => !string.IsNullOrWhiteSpace(Subject) || !string.IsNullOrWhiteSpace(HtmlBody) || !string.IsNullOrWhiteSpace(TextBody);
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (!HasContent)
|
||||
yield break;
|
||||
if (string.IsNullOrWhiteSpace(Subject))
|
||||
yield return new ValidationResult("The Subject field is required for a populated language.", [nameof(Subject)]);
|
||||
if (string.IsNullOrWhiteSpace(HtmlBody))
|
||||
yield return new ValidationResult("The HtmlBody field is required for a populated language.", [nameof(HtmlBody)]);
|
||||
if (string.IsNullOrWhiteSpace(TextBody))
|
||||
yield return new ValidationResult("The TextBody field is required for a populated language.", [nameof(TextBody)]);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
|
||||
public sealed class NotificationServiceEditViewModel
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid Revision { get; set; }
|
||||
[Required, StringLength(100)]
|
||||
public string Name { get; set; } = "";
|
||||
public List<ServiceLanguageInfo> Languages { get; set; } = [];
|
||||
public bool IsNew => Id is null;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
|
||||
public sealed class ServiceLanguageDeleteViewModel
|
||||
{
|
||||
public Guid ServiceId { get; set; }
|
||||
public Guid LanguageId { get; set; }
|
||||
public string ServiceName { get; set; } = "";
|
||||
public string Code { get; set; } = "";
|
||||
public Guid Revision { get; set; }
|
||||
public int VersionCount { get; set; }
|
||||
public bool ConfirmDelete { get; set; }
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
|
||||
public sealed class ServiceTemplatesViewModel
|
||||
{
|
||||
public required NotificationServiceInfo Service { get; init; }
|
||||
public string? KeyFilter { get; init; }
|
||||
public IReadOnlyList<EmailTemplateDefinition> Definitions { get; set; } = [];
|
||||
}
|
||||
@@ -29,7 +29,8 @@ app.MapScalarApiReference(options =>
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseHttpsRedirection();
|
||||
app.MapGet("/", () => Results.Redirect("/admin/templates"));
|
||||
app.MapGet("/", () => Results.Redirect("/admin/services"));
|
||||
app.MapControllers();
|
||||
app.MapDefaultControllerRoute();
|
||||
app.Run();
|
||||
app.Run();
|
||||
public partial class Program { }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -109,6 +109,120 @@ body {
|
||||
.table tbody tr td { vertical-align: middle; }
|
||||
.table thead th { font-size: .75rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; }
|
||||
|
||||
/* ── List actions ─────────────────────────────────────── */
|
||||
.table .template-actions,
|
||||
.table .service-actions {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Service template catalog and filters ─────────────── */
|
||||
.service-template-grid > thead > tr > th {
|
||||
--bs-table-bg: #dce7f6;
|
||||
--bs-table-color: #243a55;
|
||||
border-bottom: 2px solid #aec0db;
|
||||
}
|
||||
|
||||
.service-template-grid .template-key-filter .btn-outline-secondary {
|
||||
--bs-btn-bg: #fff;
|
||||
}
|
||||
|
||||
.service-template-catalog,
|
||||
.template-language-statuses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .375rem;
|
||||
}
|
||||
|
||||
.service-template-language-code {
|
||||
padding: .15rem .4rem;
|
||||
border: 1px solid #dce3eb;
|
||||
border-radius: .25rem;
|
||||
background: #fff;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.template-key-filter {
|
||||
min-width: 9rem;
|
||||
max-width: 32rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.template-key-filter-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .375rem;
|
||||
}
|
||||
|
||||
.template-key-filter-controls .form-control {
|
||||
flex: 1 1 9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-language-statuses {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.template-language-status {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .25rem;
|
||||
padding: .2rem .4rem;
|
||||
border: 1px solid;
|
||||
border-radius: .25rem;
|
||||
font-size: .75rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.template-language-present {
|
||||
color: #146c43;
|
||||
background: #e8f5ec;
|
||||
border-color: #b7dcc0;
|
||||
}
|
||||
|
||||
.template-language-missing {
|
||||
color: #825800;
|
||||
background: #fff4df;
|
||||
border-color: #f3d19e;
|
||||
}
|
||||
|
||||
/* ── Service language actions ─────────────────────────── */
|
||||
.service-language-table .service-language-actions {
|
||||
width: 1%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.service-language-table .service-language-action-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.service-language-table .service-language-action-buttons > .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.service-language-table .form-control {
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.service-language-table .service-language-action-buttons {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.service-language-table .service-language-action-buttons > .btn {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Empty state ──────────────────────────────────────── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
@@ -134,9 +248,7 @@ body {
|
||||
.editor-card-header-bar {
|
||||
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
|
||||
color: #fff;
|
||||
font-size: .9rem;
|
||||
font-weight: 600;
|
||||
padding: .75rem 1.25rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -145,6 +257,20 @@ body {
|
||||
|
||||
.editor-card-body { padding: 1.5rem 1.5rem 1rem; }
|
||||
|
||||
.editor-card-header-bar .editor-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .625rem;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.editor-card-title > .bi { flex-shrink: 0; }
|
||||
.editor-card-title > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.editor-card-footer {
|
||||
background-color: #f8f9fa;
|
||||
border-top: 1px solid #e9ecef;
|
||||
@@ -175,6 +301,82 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Template service context ─────────────────────────── */
|
||||
.template-editor-breadcrumbs .breadcrumb-item {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.template-service-context {
|
||||
margin: 0 0 .5rem;
|
||||
}
|
||||
|
||||
.template-service-context > dt {
|
||||
margin-bottom: .25rem;
|
||||
color: #6b7280;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.template-service-context > dd {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.template-service-edit-link:focus-visible {
|
||||
outline: 2px solid #0d6efd;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Template language sheet ──────────────────────────── */
|
||||
.template-language-sheet {
|
||||
min-width: 0;
|
||||
border: 1px solid #dce3eb;
|
||||
border-radius: .5rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.template-language-sheet > .nav-tabs {
|
||||
flex-wrap: nowrap;
|
||||
gap: .25rem;
|
||||
margin: 0;
|
||||
padding: .5rem .5rem 0;
|
||||
border-bottom: 0;
|
||||
border-radius: .5rem .5rem 0 0;
|
||||
background: #f3f6fb;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.template-language-sheet > .nav-tabs .nav-link {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-language-sheet > .nav-tabs .nav-link.active {
|
||||
border-color: #dce3eb #dce3eb #fff;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.template-language-sheet > .nav-tabs .nav-link:focus-visible {
|
||||
outline: 2px solid #0d6efd;
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.template-language-sheet > #languagePanes {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.template-language-sheet .language-pane > :last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* ── Email template preview ───────────────────────────── */
|
||||
.template-preview-panel {
|
||||
border: 1px solid #dce3eb;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
(() => {
|
||||
const form = document.getElementById('templateForm');
|
||||
const tabs = document.getElementById('languageTabs');
|
||||
const panes = document.getElementById('languagePanes');
|
||||
const activeField = document.getElementById('ActiveLanguageIndex');
|
||||
const variablesField = document.getElementById('VariablesJson');
|
||||
const samples = document.getElementById('previewVariables');
|
||||
let dirty = false;
|
||||
const sampleValues = new Map();
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replaceAll('&', '&').replaceAll('<', '<')
|
||||
.replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function activePane() {
|
||||
return panes.querySelector('.language-pane:not([hidden])');
|
||||
}
|
||||
|
||||
function selectPane(pane, focus = false) {
|
||||
Array.from(panes.children).forEach(item => { item.hidden = item !== pane; });
|
||||
Array.from(tabs.children).forEach(tab => {
|
||||
const selected = tab.dataset.pane === pane.id;
|
||||
tab.classList.toggle('active', selected);
|
||||
tab.setAttribute('aria-selected', String(selected));
|
||||
tab.tabIndex = selected ? 0 : -1;
|
||||
if (selected && focus) tab.focus();
|
||||
});
|
||||
activeField.value = Array.from(panes.children).indexOf(pane);
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function readVariables() {
|
||||
const variables = JSON.parse(variablesField.value);
|
||||
if (!Array.isArray(variables)) throw new Error('Variables must be an array.');
|
||||
const names = new Set();
|
||||
variables.forEach(variable => {
|
||||
if (!variable || typeof variable.name !== 'string' || !variable.name.trim() || typeof variable.required !== 'boolean')
|
||||
throw new Error('Every variable needs a name and a boolean required property.');
|
||||
if (Object.keys(variable).some(key => !['name', 'required'].includes(key)))
|
||||
throw new Error('Use only name and required properties for each variable.');
|
||||
if (names.has(variable.name)) throw new Error('Variable names must be unique.');
|
||||
names.add(variable.name);
|
||||
});
|
||||
return variables;
|
||||
}
|
||||
|
||||
function renderSamples() {
|
||||
const status = document.getElementById('previewStatus');
|
||||
samples.replaceChildren();
|
||||
try {
|
||||
const variables = readVariables();
|
||||
variables.forEach((variable, index) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mb-2';
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = `sample-${index}`;
|
||||
label.className = 'form-label';
|
||||
label.textContent = variable.name + (variable.required ? ' *' : '');
|
||||
const input = document.createElement('input');
|
||||
input.id = label.htmlFor;
|
||||
input.dataset.variableName = variable.name;
|
||||
input.className = 'form-control form-control-sm';
|
||||
input.value = sampleValues.get(variable.name) ?? `Sample ${variable.name}`;
|
||||
sampleValues.set(variable.name, input.value);
|
||||
input.addEventListener('input', () => { sampleValues.set(variable.name, input.value); updatePreview(); });
|
||||
wrapper.append(label, input);
|
||||
samples.append(wrapper);
|
||||
});
|
||||
status.textContent = 'Ready';
|
||||
status.className = 'badge text-bg-secondary';
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
status.className = 'badge text-bg-danger';
|
||||
}
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function interpolate(text, html = false) {
|
||||
let result = text;
|
||||
samples.querySelectorAll('input').forEach(input => {
|
||||
const name = input.dataset.variableName;
|
||||
const value = html ? escapeHtml(input.value) : input.value;
|
||||
result = result.split(`{{${name}}}`).join(value);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const pane = activePane();
|
||||
if (!pane) {
|
||||
document.getElementById('previewLanguage').textContent = '(no versions)';
|
||||
document.getElementById('previewSubject').textContent = '';
|
||||
document.getElementById('previewText').textContent = '';
|
||||
document.getElementById('previewHtmlFrame').srcdoc = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('previewLanguage').textContent = pane.querySelector('.language-code').value;
|
||||
document.getElementById('previewSubject').textContent = interpolate(pane.querySelector('[data-content="subject"]').value);
|
||||
document.getElementById('previewText').textContent = interpolate(pane.querySelector('[data-content="text"]').value);
|
||||
const html = interpolate(pane.querySelector('[data-content="html"]').value, true);
|
||||
document.getElementById('previewHtmlFrame').srcdoc = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>body{font-family:Arial,sans-serif;padding:20px;color:#1f2937;background:white}img{max-width:100%}</style></head><body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
tabs.addEventListener('click', event => {
|
||||
const tab = event.target.closest('[role="tab"]');
|
||||
if (tab) selectPane(document.getElementById(tab.dataset.pane));
|
||||
});
|
||||
tabs.addEventListener('keydown', event => {
|
||||
const offsets = { ArrowLeft: -1, ArrowRight: 1, Home: -tabs.children.length, End: tabs.children.length };
|
||||
if (!(event.key in offsets)) return;
|
||||
event.preventDefault();
|
||||
const buttons = Array.from(tabs.children);
|
||||
if (!buttons.length) return;
|
||||
let index = buttons.indexOf(event.target) + offsets[event.key];
|
||||
if (event.key === 'Home') index = 0;
|
||||
if (event.key === 'End') index = buttons.length - 1;
|
||||
const tab = buttons[(index + buttons.length) % buttons.length];
|
||||
selectPane(document.getElementById(tab.dataset.pane), true);
|
||||
});
|
||||
panes.addEventListener('input', updatePreview);
|
||||
variablesField.addEventListener('input', renderSamples);
|
||||
form.addEventListener('input', () => { dirty = true; });
|
||||
form.addEventListener('submit', () => { dirty = false; });
|
||||
window.addEventListener('beforeunload', event => {
|
||||
if (dirty) { event.preventDefault(); event.returnValue = ''; }
|
||||
});
|
||||
|
||||
renderSamples();
|
||||
})();
|
||||
@@ -0,0 +1,36 @@
|
||||
(() => {
|
||||
const form = document.getElementById('serviceForm');
|
||||
const rows = document.getElementById('serviceLanguages');
|
||||
const alert = document.getElementById('serviceLanguageAlert');
|
||||
let sequence = rows.children.length;
|
||||
let dirty = false;
|
||||
function reindex() {
|
||||
Array.from(rows.children).forEach((row, index) => row.querySelectorAll('[name]').forEach(input => {
|
||||
input.name = input.name.replace(/Languages\[\d+\]/, `Languages[${index}]`);
|
||||
}));
|
||||
}
|
||||
document.getElementById('addServiceLanguage').addEventListener('click', () => {
|
||||
const fragment = document.createElement('template');
|
||||
fragment.innerHTML = document.getElementById('serviceLanguageRow').innerHTML.replaceAll('__index__', String(sequence++));
|
||||
rows.append(fragment.content);
|
||||
reindex();
|
||||
rows.lastElementChild.querySelector('input:not([type="hidden"])').focus();
|
||||
dirty = true;
|
||||
});
|
||||
rows.addEventListener('click', event => {
|
||||
const button = event.target.closest('.remove-service-language');
|
||||
if (!button) return;
|
||||
if (Number(button.dataset.count) > 0) {
|
||||
alert.textContent = `Cannot remove this language: ${button.dataset.count} version(s) use it. Save other changes, then use the separate delete language and versions confirmation.`;
|
||||
return;
|
||||
}
|
||||
button.closest('tr').remove();
|
||||
reindex();
|
||||
dirty = true;
|
||||
});
|
||||
form.addEventListener('input', () => { dirty = true; });
|
||||
form.addEventListener('submit', () => { dirty = false; });
|
||||
window.addEventListener('beforeunload', event => {
|
||||
if (dirty) { event.preventDefault(); event.returnValue = ''; }
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user