From 9ee21205d60f2e4568556a9da21ca5c9b2d2b14e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 19:39:05 -0400 Subject: [PATCH] feat(centralui): DateTimeRangeFilter reusable from/to input component (T35c) --- .../Shared/DateTimeRangeFilter.razor | 57 ++++++++++++ .../Shared/DateTimeRangeFilterTests.cs | 87 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DateTimeRangeFilter.razor create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DateTimeRangeFilterTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DateTimeRangeFilter.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DateTimeRangeFilter.razor new file mode 100644 index 00000000..9c940f9d --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DateTimeRangeFilter.razor @@ -0,0 +1,57 @@ +@* Reusable from/to datetime-local input pair for filter bars. + INPUT-ONLY: emits raw DateTime? (Unspecified kind) via FromChanged / ToChanged. + UTC conversion is the caller's responsibility — no Apply button, no service injection. *@ + +
+
+ + +
+
+ + +
+
+ +@code { + [Parameter] public DateTime? From { get; set; } + [Parameter] public EventCallback FromChanged { get; set; } + [Parameter] public DateTime? To { get; set; } + [Parameter] public EventCallback ToChanged { get; set; } + + /// Prefix applied to all id/data-test attributes. Pages pass short codes like "no", "sc", "audit-filter". + [Parameter] public string IdPrefix { get; set; } = "dtr"; + + [Parameter] public string FromLabel { get; set; } = "From"; + [Parameter] public string ToLabel { get; set; } = "To"; + + private async Task OnFromChanged(ChangeEventArgs e) + { + var parsed = ParseInput(e.Value?.ToString()); + await FromChanged.InvokeAsync(parsed); + } + + private async Task OnToChanged(ChangeEventArgs e) + { + var parsed = ParseInput(e.Value?.ToString()); + await ToChanged.InvokeAsync(parsed); + } + + private static DateTime? ParseInput(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + return DateTime.TryParse(raw, out var dt) ? dt : null; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DateTimeRangeFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DateTimeRangeFilterTests.cs new file mode 100644 index 00000000..670cae99 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DateTimeRangeFilterTests.cs @@ -0,0 +1,87 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; + +/// +/// Component tests for — the reusable from/to +/// datetime-local input pair. The component is INPUT-ONLY: it emits raw +/// DateTime? (Unspecified kind); UTC conversion is the page's responsibility. +/// +public class DateTimeRangeFilterTests : BunitContext +{ + [Fact] + public void Renders_TwoInputs_WithPrefixedIdsAndTestHooks() + { + var cut = Render(ps => ps + .Add(p => p.IdPrefix, "sc")); + + var fromInput = cut.Find("[data-test='sc-from']"); + var toInput = cut.Find("[data-test='sc-to']"); + + Assert.Equal("datetime-local", fromInput.GetAttribute("type")); + Assert.Equal("datetime-local", toInput.GetAttribute("type")); + Assert.Equal("sc-from", fromInput.GetAttribute("id")); + Assert.Equal("sc-to", toInput.GetAttribute("id")); + } + + [Fact] + public void RendersLabels() + { + var cut = Render(ps => ps + .Add(p => p.FromLabel, "Start") + .Add(p => p.ToLabel, "End")); + + cut.Find("label[for='dtr-from']"); // default IdPrefix = "dtr" + cut.Find("label[for='dtr-to']"); + + Assert.Contains("Start", cut.Markup); + Assert.Contains("End", cut.Markup); + } + + [Fact] + public void SettingFromInput_InvokesFromChanged_WithParsedValue() + { + DateTime? captured = default; + var cut = Render(ps => ps + .Add(p => p.IdPrefix, "sc") + .Add(p => p.FromChanged, + EventCallback.Factory.Create(this, v => captured = v))); + + cut.Find("[data-test='sc-from']").Change("2026-06-01T08:30"); + + Assert.NotNull(captured); + Assert.Equal(2026, captured!.Value.Year); + Assert.Equal(6, captured.Value.Month); + Assert.Equal(1, captured.Value.Day); + Assert.Equal(8, captured.Value.Hour); + Assert.Equal(30, captured.Value.Minute); + } + + [Fact] + public void ClearingFromInput_InvokesFromChanged_WithNull() + { + DateTime? captured = new DateTime(2026, 1, 1); + var cut = Render(ps => ps + .Add(p => p.IdPrefix, "sc") + .Add(p => p.From, new DateTime(2026, 1, 1)) + .Add(p => p.FromChanged, + EventCallback.Factory.Create(this, v => captured = v))); + + cut.Find("[data-test='sc-from']").Change(""); + + Assert.Null(captured); + } + + [Fact] + public void InitialValue_RendersInInput() + { + var initial = new DateTime(2025, 12, 31, 23, 59, 0); + var cut = Render(ps => ps + .Add(p => p.From, initial)); + + var value = cut.Find("[data-test='dtr-from']").GetAttribute("value"); + Assert.Equal("2025-12-31T23:59", value); + } +}