Support added for translations in the ASP.net razor views.

This commit is contained in:
Colin Dawson 2026-02-03 16:32:50 +00:00
parent b9876b1d7b
commit 39a4f46b5b
8 changed files with 306 additions and 29 deletions

View File

@ -4,6 +4,7 @@ using e_suite.Service.Sentinel;
using e_suite.UnitTestCore;
using eSuite.API.Controllers;
using eSuite.API.SingleSignOn;
using eSuite.API.Translation;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
@ -17,6 +18,8 @@ public abstract class AccountControllerTestBase : TestBase
protected Mock<ISentinel> _sentinelMock = null!;
protected Mock<ISingleSignOn> _singleSignOnMock = null!;
protected Mock<ICookieManager> _cookieManagerMock = null!;
protected Mock<ITranslatorFactory> _translatorFactoryMock = null!;
protected Mock<ITranslator> _translatorMock = null!;
public override async Task Setup()
{
@ -27,7 +30,11 @@ public abstract class AccountControllerTestBase : TestBase
_singleSignOnMock = new Mock<ISingleSignOn>();
_cookieManagerMock = new Mock<ICookieManager>();
_accountController = new AccountController(_userManagerMock.Object,_sentinelMock.Object, _singleSignOnMock.Object, _cookieManagerMock.Object);
_translatorMock = new Mock<ITranslator>();
_translatorFactoryMock = new Mock<ITranslatorFactory>();
_translatorFactoryMock.Setup(x => x.Use(It.IsAny<Namespaces>())).ReturnsAsync(_translatorMock.Object);
_accountController = new AccountController(_userManagerMock.Object,_sentinelMock.Object, _singleSignOnMock.Object, _cookieManagerMock.Object, _translatorFactoryMock.Object);
}
protected void AddAuthorisedUserToController(long id, string email, string displayName)

View File

@ -1,4 +1,5 @@
using e_suite.API.Common.models;
using eSuite.API.Controllers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
@ -45,9 +46,13 @@ public class ProfileGetUnitTests : AccountControllerTestBase
Assert.That(response, Is.TypeOf<PartialViewResult>());
var viewResult = response as PartialViewResult;
Assert.That(viewResult?.ViewName, Is.EqualTo("Profile"));
Assert.That(viewResult?.Model, Is.TypeOf<Models.UserProfile>());
var actualProfile = viewResult?.Model as Models.UserProfile;
Assert.That(viewResult?.Model, Is.TypeOf<ProfileViewModel>());
var profileViewModel = viewResult!.Model as ProfileViewModel;
Assert.That(profileViewModel.Profile, Is.TypeOf<Models.UserProfile>());
var actualProfile = profileViewModel.Profile;
Assert.That(actualProfile, Is.Not.Null);
Assert.That(actualProfile?.Email, Is.EqualTo(userProfile.Email));
Assert.That(actualProfile?.FirstName, Is.EqualTo(userProfile.FirstName));

View File

@ -8,6 +8,7 @@ using e_suite.Service.Sentinel;
using eSuite.API.Extensions;
using eSuite.API.security;
using eSuite.API.SingleSignOn;
using eSuite.API.Translation;
using eSuite.API.Utilities;
using eSuite.Core.Security;
using Microsoft.AspNetCore.Authorization;
@ -15,6 +16,12 @@ using Microsoft.AspNetCore.Mvc;
namespace eSuite.API.Controllers;
public class ProfileViewModel
{
public Models.UserProfile Profile { get; set; }
public ITranslatorFactory TranslatorFactory { get; set; }
}
/// <summary>
/// This MVC controller is used to support all user interactions when logging in and out of the system, including profile updates.
/// </summary>
@ -26,7 +33,7 @@ public class AccountController : ESuiteController
private readonly ISentinel _sentinel;
private readonly ISingleSignOn _singleSignOn;
private readonly ICookieManager _cookieManager;
private readonly ITranslatorFactory _translatorFactory;
/// <summary>
/// Default constructor used to inject dependencies
/// </summary>
@ -34,12 +41,13 @@ public class AccountController : ESuiteController
/// <param name="sentinel"></param>
/// <param name="singleSignOn"></param>
/// <param name="cookieManager"></param>
public AccountController(IUserManager userManager, ISentinel sentinel, ISingleSignOn singleSignOn, ICookieManager cookieManager)
public AccountController(IUserManager userManager, ISentinel sentinel, ISingleSignOn singleSignOn, ICookieManager cookieManager, ITranslatorFactory translatorFactory)
{
_userManager = userManager;
_sentinel = sentinel;
_singleSignOn = singleSignOn;
_cookieManager = cookieManager;
_translatorFactory = translatorFactory;
}
private const string AccountProfileUrl = "~/account/profile";
@ -222,7 +230,7 @@ public class AccountController : ESuiteController
return Ok();
}
/// <summary>
/// Page used for making alterations to a users profile.
/// </summary>
@ -234,9 +242,17 @@ public class AccountController : ESuiteController
public async Task<IActionResult> ProfileGet(CancellationToken cancellationToken)
{
var profile = await _userManager.GetProfile(User.Email(), cancellationToken);
var userProfile = profile.ToUserProfile();
Models.UserProfile userProfile = profile.ToUserProfile();
return PartialView("Profile", userProfile);
_translatorFactory.Locale = profile.PreferredLocale;
var profileViewModel = new ProfileViewModel
{
Profile = userProfile,
TranslatorFactory = _translatorFactory
};
return PartialView("Profile", profileViewModel);
}
/// <summary>

View File

@ -1,7 +1,7 @@
using Autofac;
using e_suite.DependencyInjection;
using eSuite.API.SingleSignOn;
using eSuite.Core.Clock;
using eSuite.API.Translation;
namespace eSuite.API.DependencyInjection;
@ -23,6 +23,16 @@ internal class CoreRegistrationModule : ESuiteModule
builder.RegisterType<CookieManager>().As<ICookieManager>();
builder.RegisterType<HttpClientFacade>().As<IHttpClientFacade>();
builder.RegisterType<JsonLocalizationService>()
.As<IJsonLocalizationService>()
.SingleInstance(); // safe to cache globally
builder.RegisterType<TranslatorFactory>()
.As<ITranslatorFactory>()
.InstancePerLifetimeScope(); // per-request, because locale is per-request
//e_suite.Service.Mail.IocRegistration.RegisterTypes(builder);
//e_suite.Service.Sentinel.IocRegistration.RegisterTypes(builder);

View File

@ -6,8 +6,10 @@ using eSuite.API.DependencyInjection;
using eSuite.API.HealthChecks;
using eSuite.API.Middleware;
using eSuite.API.Swagger;
using eSuite.API.Translation;
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.SwaggerUI;
using System.Text.Json.Serialization;
@ -49,6 +51,21 @@ builder.Services.AddHealthChecks()
.AddCheck("Mail Server", () => SmtpHealthCheck.Healthy(builder.Configuration, new SocketFactory()));
builder.Services.AddAntiforgery(options => options.HeaderName = "XSRF-TOKEN");
//builder.Services.Configure<FrontendSettings>(builder.Configuration);
//builder.Services.AddHttpClient<IJsonLocalizationService, JsonLocalizationService>()
// .ConfigureHttpClient((sp, client) =>
// {
// var settings = sp.GetRequiredService<IOptions<FrontendSettings>>().Value;
// // Ensure trailing slash
// var baseUrl = settings.BaseUrl.EndsWith("/")
// ? settings.BaseUrl
// : settings.BaseUrl + "/";
// client.BaseAddress = new Uri(baseUrl);
// });
var app = builder.Build();
using (var scope = app.Services.CreateScope())
@ -85,3 +102,9 @@ app.UseMiddleware<SecurityAccessMiddleWare>();
app.MapControllers().RequireAuthorization(); //This ensures that ALL API calls need a Bearer token, unless marked [AllowAnonymous] DO NOT REMOVE!
app.Run();
public class FrontendSettings
{
public string BaseUrl { get; set; }
}

View File

@ -0,0 +1,217 @@
using eSuite.API.SingleSignOn;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using System.Text.Json;
using System.Text.RegularExpressions;
using e_suite.API.Common.extensions;
namespace eSuite.API.Translation;
[AttributeUsage(AttributeTargets.Field)]
public class NamespaceValueAttribute : Attribute
{
public string Value { get; }
public NamespaceValueAttribute(string value)
{
Value = value;
}
}
public enum Namespaces
{
[NamespaceValue("common")]
Common,
[NamespaceValue("mailTypes")]
MailTypes,
[NamespaceValue("htmlIsland")]
HtmlIsland
}
public static class NamespaceExtensions
{
public static string ToNamespaceString(this Namespaces ns)
{
var type = ns.GetType();
var member = type.GetMember(ns.ToString()).FirstOrDefault();
var attr = member?
.GetCustomAttributes(typeof(NamespaceValueAttribute), false)
.Cast<NamespaceValueAttribute>()
.FirstOrDefault();
return attr?.Value ?? ns.ToString();
}
}
public static class I18nInterpolation
{
private static readonly Regex TokenRegex = new Regex(@"{{\s*(\w+)\s*}}",
RegexOptions.Compiled);
public static string Interpolate(string template, IDictionary<string, object> values)
{
if (template == null)
return string.Empty;
return TokenRegex.Replace(template, match =>
{
var key = match.Groups[1].Value;
if (values != null && values.TryGetValue(key, out var val))
return val?.ToString() ?? string.Empty;
return match.Value;
});
}
}
public interface ITranslator
{
string T(string key, object values = null);
}
public class Translator : ITranslator
{
private readonly IDictionary<string, string> _translations;
public Translator(IDictionary<string, string> translations)
{
_translations = translations;
}
public string T(string key, object values = null)
{
if (!_translations.TryGetValue(key, out var template))
return key;
var dict = values?
.GetType()
.GetProperties()
.ToDictionary(p => p.Name, p => p.GetValue(values));
return I18nInterpolation.Interpolate(template, dict);
}
}
public static class LocaleFallback
{
public static IEnumerable<string> ResolveFallbacks(string locale, string defaultLocale = "en")
{
if (!string.IsNullOrWhiteSpace(locale))
{
var parts = locale.Split('-');
// Default first
if (!string.Equals(locale, defaultLocale, StringComparison.OrdinalIgnoreCase))
yield return defaultLocale;
// Language-only
if (parts.Length > 1)
yield return parts[0];
// Full locale last (most specific)
yield return locale;
}
else
{
yield return defaultLocale;
}
}
}
public interface IJsonLocalizationService
{
Task<IDictionary<string, string>> LoadNamespaceAsync(string locale, string ns);
}
public class JsonLocalizationService : IJsonLocalizationService
{
private readonly IHttpClientFacade _http;
private readonly IMemoryCache _cache;
private readonly string _baseUrl;
public JsonLocalizationService(
IHttpClientFacade http,
IMemoryCache cache,
IConfiguration configuration
)
{
_http = http;
_cache = cache;
_baseUrl = configuration.GetConfigValue("BASE_URL", "baseUrl", "http://localhost:3000");
}
public Task<IDictionary<string, string>> LoadNamespaceAsync(string locale, string ns)
{
var cacheKey = $"i18n:{locale}:{ns}";
return _cache.GetOrCreateAsync(cacheKey, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
return await LoadNamespaceInternalAsync(locale, ns);
});
}
private async Task<IDictionary<string, string>> LoadNamespaceInternalAsync(string locale, string ns)
{
var result = new Dictionary<string, string>();
foreach (var loc in LocaleFallback.ResolveFallbacks(locale))
{
var url = new Uri($"{_baseUrl}/locales/{loc}/{ns}.json");
try
{
var json = await _http.GetStringAsync(url, CancellationToken.None);
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
foreach (var kvp in dict)
result[kvp.Key] = kvp.Value;
}
catch
{
// ignore missing files
}
}
return result;
}
}
public interface ITranslatorFactory
{
string Locale { get; set; }
Task<ITranslator> Use(params Namespaces[] namespaces);
}
public class TranslatorFactory : ITranslatorFactory
{
private readonly IJsonLocalizationService _localizer;
public string Locale { get; set; } = "en-GB";
public TranslatorFactory(IJsonLocalizationService localizer)
{
_localizer = localizer;
}
public async Task<ITranslator> Use(params Namespaces[] namespaces)
{
var merged = new Dictionary<string, string>();
foreach (var ns in namespaces)
{
var nsString = ns.ToNamespaceString();
var dict = await _localizer.LoadNamespaceAsync(Locale, nsString);
foreach (var kvp in dict)
merged[kvp.Key] = kvp.Value;
}
return new Translator(merged);
}
}

View File

@ -1,46 +1,45 @@
@model eSuite.API.Models.UserProfile
@using eSuite.API.Translation
@model eSuite.API.Controllers.ProfileViewModel
@{
ViewBag.Title = "e-suite";
//Layout = "~/Views/Shared/_layout.cshtml";
var t = await Model.TranslatorFactory.Use(Namespaces.Common);
Layout = null;
}
<div>
<h1>Profile</h1>
@using (Html.BeginForm(FormMethod.Post, true, null))
{
@Html.AntiForgeryToken()
<div class="form-group">
@Html.LabelFor(m => m.Email)
@Html.EditorFor(m => m.Email, new { htmlAttributes = new
@Html.LabelFor(m => m.Profile.Email,t.T("Email"))
@Html.EditorFor(m => m.Profile.Email, new { htmlAttributes = new
{
@class = "form-control",
@disabled = "disabled"
} })
@Html.ValidationMessageFor(m => m.Email)
@Html.ValidationMessageFor(m => m.Profile.Email)
</div>
<div class="form-group">
@Html.LabelFor(m => m.FirstName)
@Html.EditorFor(m => m.FirstName, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(m => m.FirstName)
@Html.LabelFor(m => m.Profile.FirstName, t.T("FirstName"))
@Html.EditorFor(m => m.Profile.FirstName, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(m => m.Profile.FirstName)
</div>
<div class="form-group">
@Html.LabelFor(m => m.MiddleNames)
@Html.EditorFor(m => m.MiddleNames, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(m => m.MiddleNames)
@Html.LabelFor(m => m.Profile.MiddleNames, t.T("MiddleNames"))
@Html.EditorFor(m => m.Profile.MiddleNames, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(m => m.Profile.MiddleNames)
</div>
<div class="form-group">
@Html.LabelFor(m => m.LastName)
@Html.EditorFor(m => m.LastName, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(m => m.LastName)
@Html.LabelFor(m => m.Profile.LastName, t.T("LastName"))
@Html.EditorFor(m => m.Profile.LastName, new { htmlAttributes = new { @class = "form-control" } })
@Html.ValidationMessageFor(m => m.Profile.LastName)
</div>
@await Html.PartialAsync("_loginMethodChooser")
@await Html.PartialAsync("_loginMethodChooser", Model.Profile)
<button type="submit" class="btn btn-primary btn-spaced" name="Submit">Save</button>
<button type="submit" class="btn btn-primary btn-spaced" name="Submit">@t.T("Save")</button>
}
</div>

View File

@ -9,8 +9,8 @@
&lt;/TestAncestor&gt;&#xD;
&lt;/SessionState&gt;</s:String>
<s:Boolean x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/@KeyIndexDefined">True</s:Boolean>
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/Coords/@EntryValue">(Doc Ln 120 Col 0)</s:String>
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/FileId/@EntryValue">7DC1F493-76A5-3740-E774-C8DAA51ED83A/f:PatchUnitTests.cs</s:String>
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/Coords/@EntryValue">(Doc Ln 140 Col 8)</s:String>
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/FileId/@EntryValue">4A704FA7-4E3A-4CFA-B043-434A0C49AF89/d:Translation/f:TranslatorFactory.cs</s:String>
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark1/Owner/@EntryValue">NumberedBookmarkManager</s:String>
<s:Boolean x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark2/@KeyIndexDefined">True</s:Boolean>
<s:String x:Key="/Default/Housekeeping/Bookmarks/NumberedBookmarks/=Bookmark2/Coords/@EntryValue">(Doc Ln 606 Col 8)</s:String>