diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0d24c693..d6e290f1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,22 +4,60 @@ on: push: branches: - dev - workflow_dispatch: +concurrency: + group: mudextensions-deploy + cancel-in-progress: true + +permissions: + contents: read + packages: write + jobs: - deploy: + build-and-deploy: runs-on: ubuntu-latest steps: - - name: Deploy to Server via SSH + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/codebeamorg/codebeam.mudblazor.extensions:dev + ghcr.io/codebeamorg/codebeam.mudblazor.extensions:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Deploy to Server uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | + set -e + cd ~/apps/CodeBeam.MudBlazor.Extensions - git pull origin dev - docker compose up -d --build - + + git fetch origin dev + git reset --hard origin/dev + + docker compose pull + docker compose up -d --remove-orphans + + docker image prune -f diff --git a/.github/workflows/virtualization-benchmark-probe.yml b/.github/workflows/virtualization-benchmark-probe.yml new file mode 100644 index 00000000..5045d38b --- /dev/null +++ b/.github/workflows/virtualization-benchmark-probe.yml @@ -0,0 +1,39 @@ +name: Virtualization benchmark probe + +on: + push: + branches: + - upstream/benchmarks-virtualized-select-selection-state + pull_request: + branches: + - dev + paths: + - 'benchmarks/**' + - '.github/workflows/virtualization-benchmark-probe.yml' + workflow_dispatch: + +jobs: + probe: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Run upstream-to-PR1 structural probe + shell: pwsh + run: ./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly -BaselineRef "7b5faf7ebb7666558d13c447313e9b09c92a110d" -FixedRef "e7020d49a824a371ab3f3450255f10d9cfad8783" -ResultsDirectory "$env:GITHUB_WORKSPACE/benchmark-results" + + - name: Upload probe results + if: always() + uses: actions/upload-artifact@v4 + with: + name: virtualization-benchmark-probe + path: benchmark-results diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 00000000..4d56d880 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,5 @@ +:80 { + root * /usr/share/caddy + file_server + try_files {path} /index.html +} diff --git a/CodeBeamMudExtensions.slnx b/CodeBeamMudExtensions.slnx index 089d99fc..44298196 100644 --- a/CodeBeamMudExtensions.slnx +++ b/CodeBeamMudExtensions.slnx @@ -1,4 +1,7 @@ + + + diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/BenchmarkBunitContext.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/BenchmarkBunitContext.cs new file mode 100644 index 00000000..b2f89739 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/BenchmarkBunitContext.cs @@ -0,0 +1,23 @@ +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using MudBlazor.Services; +using MudExtensions.Services; + +namespace MudExtensions.Benchmarks; + +internal static class BenchmarkBunitContext +{ + public static BunitContext Create() + { + var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddMudServices(options => + { + options.SnackbarConfiguration.ShowTransitionDuration = 0; + options.SnackbarConfiguration.HideTransitionDuration = 0; + options.PopoverOptions.CheckForPopoverProvider = false; + }); + context.Services.AddMudExtensions(); + return context; + } +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks.csproj b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks.csproj new file mode 100644 index 00000000..811ff202 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + false + MudExtensions.Benchmarks + + + + + + + + + + + + diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs new file mode 100644 index 00000000..91558b34 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ListExtendedInitialRenderBenchmarks.cs @@ -0,0 +1,38 @@ +using BenchmarkDotNet.Attributes; +using Bunit; + +namespace MudExtensions.Benchmarks; + +[MemoryDiagnoser] +[BenchmarkCategory("MudListExtended", "InitialRender")] +public class ListExtendedInitialRenderBenchmarks +{ + private List _items = null!; + private int?[] _selectedValues = null!; + + [Params(10, 100, 1_000, 4_000)] + public int ItemCount { get; set; } + + [Params(false, true)] + public bool Virtualize { get; set; } + + [GlobalSetup] + public void Setup() + { + _items = Enumerable.Range(1, ItemCount).Select(static value => (int?)value).ToList(); + _selectedValues = [1, ItemCount]; + } + + [Benchmark] + public async Task RenderList() + { + await using var context = BenchmarkBunitContext.Create(); + var cut = context.Render>(parameters => parameters + .Add(x => x.ItemCollection, _items) + .Add(x => x.Virtualize, Virtualize) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, _selectedValues)); + + return cut.RenderCount; + } +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs new file mode 100644 index 00000000..687cc98d --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/Program.cs @@ -0,0 +1,10 @@ +using BenchmarkDotNet.Running; +using MudExtensions.Benchmarks; + +if (args.Length > 0 && string.Equals(args[0], "probe", StringComparison.OrdinalIgnoreCase)) +{ + return await ScaleProbe.RunAsync(args[1..]); +} + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); +return 0; diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs new file mode 100644 index 00000000..91e312bd --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/ScaleProbe.cs @@ -0,0 +1,206 @@ +using System.Diagnostics; +using System.Globalization; +using Bunit; + +namespace MudExtensions.Benchmarks; + +internal static class ScaleProbe +{ + public static async Task RunAsync(string[] args) + { + var outputPath = GetOption(args, "--output"); + var variant = Environment.GetEnvironmentVariable("BENCHMARK_VARIANT") ?? "current"; + + await WarmUpAsync(); + + var results = new List(); + foreach (var scenario in Scenarios()) + { + results.Add(await MeasureAsync(variant, scenario)); + } + + WriteTable(results); + + if (!string.IsNullOrWhiteSpace(outputPath)) + { + var fullPath = Path.GetFullPath(outputPath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + await File.WriteAllTextAsync(fullPath, ToCsv(results)); + Console.WriteLine($"\nWrote {fullPath}"); + } + + return 0; + } + + private static async Task MeasureAsync(string variant, ProbeScenario scenario) + { + var items = Enumerable.Range(1, scenario.ItemCount).Select(static value => (int?)value).ToList(); + var selectedValues = CreateSelectedValues(scenario.ItemCount, scenario.SelectedCount); + + await using var context = BenchmarkBunitContext.Create(); + + GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true); + GC.WaitForPendingFinalizers(); + var retainedBefore = GC.GetTotalMemory(forceFullCollection: true); + var allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + + var stopwatch = Stopwatch.StartNew(); + var cut = context.Render(parameters => parameters + .Add(x => x.Items, items) + .Add(x => x.SelectedValues, selectedValues) + .Add(x => x.SelectCount, scenario.SelectCount) + .Add(x => x.Virtualize, scenario.Virtualize)); + stopwatch.Stop(); + + var allocatedAfter = GC.GetTotalAllocatedBytes(precise: true); + var retainedAfter = GC.GetTotalMemory(forceFullCollection: true); + + var shadowItemCount = cut.FindAll("div[style='display: none'] div.mud-list-item-extended").Count; + var selectItemComponentCount = cut.FindComponents>().Count; + var markupLength = cut.Markup.Length; + + return new( + variant, + scenario.ItemCount, + scenario.SelectCount, + selectedValues.Length, + scenario.Virtualize, + stopwatch.Elapsed.TotalMilliseconds, + allocatedAfter - allocatedBefore, + retainedAfter - retainedBefore, + cut.RenderCount, + shadowItemCount, + selectItemComponentCount, + markupLength); + } + + private static int?[] CreateSelectedValues(int itemCount, int selectedCount) + { + if (selectedCount <= 0) + { + return []; + } + + if (selectedCount == 1) + { + return [itemCount]; + } + + return Enumerable.Range(0, Math.Min(itemCount, selectedCount)) + .Select(index => (int?)(1 + (index * (itemCount - 1) / (Math.Min(itemCount, selectedCount) - 1)))) + .Distinct() + .ToArray(); + } + + private static IEnumerable Scenarios() + { + foreach (var itemCount in new[] { 10, 100, 1_000, 4_000 }) + { + yield return new(itemCount, 1, 2, true); + } + + yield return new(4_000, 5, 2, true); + yield return new(4_000, 20, 2, true); + + foreach (var itemCount in new[] { 10, 100, 1_000, 4_000 }) + { + yield return new(itemCount, 1, 2, false); + } + + foreach (var selectedCount in new[] { 1, 10, 30, 100 }) + { + yield return new(4_000, 1, selectedCount, true); + } + } + + private static async Task WarmUpAsync() + { + var items = Enumerable.Range(1, 10).Select(static value => (int?)value).ToList(); + await using var context = BenchmarkBunitContext.Create(); + context.Render(parameters => parameters + .Add(x => x.Items, items) + .Add(x => x.SelectedValues, new int?[] { 1, 10 }) + .Add(x => x.Virtualize, true)); + } + + private static void WriteTable(IEnumerable results) + { + Console.WriteLine("Variant Items Selects Selected Virt Time ms Allocated Retained* Renders ShadowItems Components Markup"); + foreach (var result in results) + { + Console.WriteLine( + $"{result.Variant,-9} {result.ItemCount,5} {result.SelectCount,7} {result.SelectedCount,8} {result.Virtualize,4} " + + $"{result.ElapsedMilliseconds,8:F2} {FormatBytes(result.AllocatedBytes),11} {FormatBytes(result.ApproxRetainedBytes),11} " + + $"{result.RenderCount,8} {result.ShadowItemCount,11} {result.SelectItemComponentCount,10} {result.MarkupLength,6}"); + } + + Console.WriteLine("\n* Retained is an approximate full-GC delta while the rendered component remains alive; use BenchmarkDotNet allocation/GC results for statistical comparisons."); + } + + private static string ToCsv(IEnumerable results) + { + var lines = new List + { + "Variant,ItemCount,SelectCount,SelectedCount,Virtualize,ElapsedMilliseconds,AllocatedBytes,ApproxRetainedBytes,RenderCount,ShadowItemCount,SelectItemComponentCount,MarkupLength" + }; + + lines.AddRange(results.Select(result => string.Join(',', + Csv(result.Variant), + result.ItemCount.ToString(CultureInfo.InvariantCulture), + result.SelectCount.ToString(CultureInfo.InvariantCulture), + result.SelectedCount.ToString(CultureInfo.InvariantCulture), + result.Virtualize.ToString(CultureInfo.InvariantCulture), + result.ElapsedMilliseconds.ToString("F4", CultureInfo.InvariantCulture), + result.AllocatedBytes.ToString(CultureInfo.InvariantCulture), + result.ApproxRetainedBytes.ToString(CultureInfo.InvariantCulture), + result.RenderCount.ToString(CultureInfo.InvariantCulture), + result.ShadowItemCount.ToString(CultureInfo.InvariantCulture), + result.SelectItemComponentCount.ToString(CultureInfo.InvariantCulture), + result.MarkupLength.ToString(CultureInfo.InvariantCulture)))); + + return string.Join(Environment.NewLine, lines) + Environment.NewLine; + } + + private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\""; + + private static string FormatBytes(long bytes) + { + var sign = bytes < 0 ? "-" : string.Empty; + var absolute = Math.Abs((double)bytes); + return absolute switch + { + >= 1024 * 1024 => $"{sign}{absolute / (1024 * 1024):F1} MB", + >= 1024 => $"{sign}{absolute / 1024:F1} KB", + _ => $"{bytes} B" + }; + } + + private static string? GetOption(string[] args, string name) + { + for (var index = 0; index < args.Length - 1; index++) + { + if (string.Equals(args[index], name, StringComparison.OrdinalIgnoreCase)) + { + return args[index + 1]; + } + } + + return null; + } + + private sealed record ProbeScenario(int ItemCount, int SelectCount, int SelectedCount, bool Virtualize); + + private sealed record ProbeResult( + string Variant, + int ItemCount, + int SelectCount, + int SelectedCount, + bool Virtualize, + double ElapsedMilliseconds, + long AllocatedBytes, + long ApproxRetainedBytes, + int RenderCount, + int ShadowItemCount, + int SelectItemComponentCount, + int MarkupLength); +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectBenchmarkHost.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectBenchmarkHost.cs new file mode 100644 index 00000000..5b9bd78e --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectBenchmarkHost.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace MudExtensions.Benchmarks; + +internal sealed class SelectBenchmarkHost : ComponentBase +{ + [Parameter, EditorRequired] + public ICollection Items { get; set; } = []; + + [Parameter, EditorRequired] + public IEnumerable SelectedValues { get; set; } = []; + + [Parameter] + public int SelectCount { get; set; } = 1; + + [Parameter] + public bool Virtualize { get; set; } = true; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + for (var index = 0; index < SelectCount; index++) + { + builder.OpenComponent>(0); + builder.SetKey(index); + builder.AddAttribute(1, nameof(MudSelectExtended.ItemCollection), Items); + builder.AddAttribute(2, nameof(MudSelectExtended.Virtualize), Virtualize); + builder.AddAttribute(3, nameof(MudSelectExtended.MultiSelection), true); + builder.AddAttribute(4, nameof(MudSelectExtended.SelectedValues), SelectedValues); + builder.CloseComponent(); + } + } +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs new file mode 100644 index 00000000..a3629960 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectInitialRenderBenchmarks.cs @@ -0,0 +1,48 @@ +using BenchmarkDotNet.Attributes; +using Bunit; + +namespace MudExtensions.Benchmarks; + +[MemoryDiagnoser] +[BenchmarkCategory("MudSelectExtended", "InitialRender")] +public class SelectInitialRenderBenchmarks +{ + private List _items = null!; + private int?[] _selectedValues = null!; + private string _expectedText = null!; + + [Params(10, 100, 1_000, 4_000)] + public int ItemCount { get; set; } + + [Params(false, true)] + public bool Virtualize { get; set; } + + [GlobalSetup] + public void Setup() + { + _items = Enumerable.Range(1, ItemCount).Select(static value => (int?)value).ToList(); + _selectedValues = [1, ItemCount]; + _expectedText = $"1, {ItemCount}"; + } + + [Benchmark] + public async Task RenderSelect() + { + await using var context = BenchmarkBunitContext.Create(); + var cut = context.Render(parameters => parameters + .Add(x => x.Items, _items) + .Add(x => x.SelectedValues, _selectedValues) + .Add(x => x.Virtualize, Virtualize)); + + cut.WaitForAssertion(() => + { + var value = cut.Find("input").GetAttribute("value"); + if (!string.Equals(value, _expectedText, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Expected settled select text '{_expectedText}', but found '{value}'."); + } + }); + + return cut.FindComponents>().Count; + } +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs new file mode 100644 index 00000000..cc9d8e42 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectMultiplicityBenchmarks.cs @@ -0,0 +1,36 @@ +using BenchmarkDotNet.Attributes; +using Bunit; + +namespace MudExtensions.Benchmarks; + +[MemoryDiagnoser] +[BenchmarkCategory("MudSelectExtended", "Multiplicity")] +public class SelectMultiplicityBenchmarks +{ + private List _items = null!; + private int?[] _selectedValues = null!; + + [Params(1, 5, 20)] + public int SelectCount { get; set; } + + [GlobalSetup] + public void Setup() + { + const int itemCount = 4_000; + _items = Enumerable.Range(1, itemCount).Select(static value => (int?)value).ToList(); + _selectedValues = [17, 3_999]; + } + + [Benchmark] + public async Task RenderVirtualizedSelects() + { + await using var context = BenchmarkBunitContext.Create(); + var cut = context.Render(parameters => parameters + .Add(x => x.Items, _items) + .Add(x => x.SelectedValues, _selectedValues) + .Add(x => x.SelectCount, SelectCount) + .Add(x => x.Virtualize, true)); + + return cut.RenderCount; + } +} diff --git a/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs new file mode 100644 index 00000000..68d9f219 --- /dev/null +++ b/benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks/SelectSelectionDensityBenchmarks.cs @@ -0,0 +1,38 @@ +using BenchmarkDotNet.Attributes; +using Bunit; + +namespace MudExtensions.Benchmarks; + +[MemoryDiagnoser] +[BenchmarkCategory("MudSelectExtended", "SelectionDensity")] +public class SelectSelectionDensityBenchmarks +{ + private const int ItemCount = 4_000; + private List _items = null!; + private int?[] _selectedValues = null!; + + [Params(1, 2, 10, 30, 100)] + public int SelectedCount { get; set; } + + [GlobalSetup] + public void Setup() + { + _items = Enumerable.Range(1, ItemCount).Select(static value => (int?)value).ToList(); + _selectedValues = Enumerable.Range(1, SelectedCount) + .Select(index => (int?)(1 + ((index - 1) * (ItemCount - 1) / Math.Max(1, SelectedCount - 1)))) + .Distinct() + .ToArray(); + } + + [Benchmark] + public async Task RenderVirtualizedSelect() + { + await using var context = BenchmarkBunitContext.Create(); + var cut = context.Render(parameters => parameters + .Add(x => x.Items, _items) + .Add(x => x.SelectedValues, _selectedValues) + .Add(x => x.Virtualize, true)); + + return cut.RenderCount; + } +} diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..d423a25d --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,86 @@ +# Virtualized selection benchmarks + +This companion benchmark harness provides reproducible performance evidence for upstream PR #648. + +PR #648 is the production fix. This benchmark work is optional and should not be accepted independently of that PR. + +## Pinned comparison + +The authoritative comparison is: + +- upstream `dev`: `7b5faf7ebb7666558d13c447313e9b09c92a110d` +- PR #648 candidate: `e7020d49a824a371ab3f3450255f10d9cfad8783` + +The runner deliberately pins both commits. PR2 itself contains benchmark infrastructure, so using PR2 `HEAD` as the fixed side would measure the wrong Git point. + +A normal merge of PR #648 is the simplest path because it preserves the candidate SHA. If PR #648 is squash-merged or rebase-merged, update `FixedRef` to the resulting upstream commit and rebase this companion branch before merging it. + +## What is measured + +The benchmark project uses bUnit to execute the real Blazor component lifecycle. + +The structural probe records: + +- hidden shadow-list DOM item count; +- materialized `MudSelectItemExtended` component count; +- bUnit render count; +- generated markup length; +- one-shot render time; +- total allocated bytes; +- an explicitly labelled approximate retained-memory delta after a forced full GC while the rendered component remains alive. + +The probe matrix includes: + +- 10, 100, 1,000 and 4,000 items; +- virtualized and non-virtualized controls; +- 1, 5 and 20 simultaneous selects; +- selection densities from 1 through 100 selected values. + +Direct `MudListExtended` BenchmarkDotNet cases remain available as a control. + +## CI + +The GitHub Actions workflow runs the structural probe against the two immutable commits and uploads the raw results plus generated comparison summary. + +Hosted-runner elapsed timings are illustrative because runner hardware and contention vary. Do not use elapsed-time thresholds as a merge gate. Structural component counts, allocation behavior and scaling shape are the stronger CI signals. + +The automated workflow intentionally does **not** run the current initial-render BenchmarkDotNet scenario. During development that scenario did not distinguish the known shadow-list pathology reliably enough to be useful headline evidence. + +## Running locally + +Requirements: + +- Git +- PowerShell 7+ +- .NET 10 SDK + +Run the pinned structural comparison: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 -ProbeOnly +``` + +Run the complete benchmark suite manually: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 +``` + +Run a quick BenchmarkDotNet smoke test: + +```powershell +./benchmarks/run-virtualization-benchmarks.ps1 ` + -SkipProbe ` + -Quick ` + -BenchmarkFilter "*SelectInitialRenderBenchmarks*" +``` + +For any published evidence, pass explicit refs and retain `run-info.txt` plus each variant's `source.txt`. + +## Interpretation + +The important scaling property is that `Virtualize=true` must not cause `MudSelectExtended` to instantiate one hidden item component for every member of `ItemCollection` merely to retain selected-value presentation state. + +Large reductions in materialized component count and managed allocation are therefore directly meaningful. Absolute timings from shared CI are secondary evidence. + +This benchmark companion exists to make the performance claim behind PR #648 inspectable and repeatable; it is not required for the production fix itself. diff --git a/benchmarks/run-virtualization-benchmarks.ps1 b/benchmarks/run-virtualization-benchmarks.ps1 new file mode 100644 index 00000000..304e2228 --- /dev/null +++ b/benchmarks/run-virtualization-benchmarks.ps1 @@ -0,0 +1,251 @@ +[CmdletBinding()] +param( + [string]$BaselineRef = "7b5faf7ebb7666558d13c447313e9b09c92a110d", + [string]$FixedRef = "e7020d49a824a371ab3f3450255f10d9cfad8783", + [string]$ResultsDirectory, + [string]$BenchmarkFilter = "*", + [switch]$Quick, + [switch]$ProbeOnly, + [switch]$SkipProbe +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if ($ProbeOnly -and $SkipProbe) { + throw "ProbeOnly and SkipProbe cannot be used together." +} + +$repoRoot = (& git -C $PSScriptRoot rev-parse --show-toplevel).Trim() +if ($LASTEXITCODE -ne 0) { + throw "Unable to locate the repository root." +} + +if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) { + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + $ResultsDirectory = Join-Path $repoRoot "BenchmarkDotNet.Artifacts/virtualized-list-selection-state/$timestamp" +} + +$ResultsDirectory = [IO.Path]::GetFullPath($ResultsDirectory) +New-Item -ItemType Directory -Path $ResultsDirectory -Force | Out-Null + +function Resolve-Commit([string]$ref) { + $sha = (& git -C $repoRoot rev-parse "$ref^{commit}").Trim() + if ($LASTEXITCODE -ne 0) { + throw "Unable to resolve '$ref'." + } + + return $sha +} + +function Invoke-Checked([string]$command, [string[]]$arguments) { + & $command @arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed ($LASTEXITCODE): $command $($arguments -join ' ')" + } +} + +$baselineSha = Resolve-Commit $BaselineRef +$fixedSha = Resolve-Commit $FixedRef +$benchmarksSource = Join-Path $repoRoot "benchmarks" +$workRoot = Join-Path ([IO.Path]::GetTempPath()) "CodeBeamMudExtensionsBenchmarks-$([Guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $workRoot -Force | Out-Null + +$previousCi = $env:CI +$previousVariant = $env:BENCHMARK_VARIANT +$worktrees = [System.Collections.Generic.List[string]]::new() + +function Invoke-Variant([string]$name, [string]$sha) { + $worktree = Join-Path $workRoot $name + $worktrees.Add($worktree) + + Write-Host "`n=== $name ($sha) ===" -ForegroundColor Cyan + Invoke-Checked git @("-C", $repoRoot, "worktree", "add", "--detach", $worktree, $sha) + + $targetBenchmarks = Join-Path $worktree "benchmarks" + New-Item -ItemType Directory -Path $targetBenchmarks -Force | Out-Null + Copy-Item -Path (Join-Path $benchmarksSource "*") -Destination $targetBenchmarks -Recurse -Force + + $variantResults = Join-Path $ResultsDirectory $name + New-Item -ItemType Directory -Path $variantResults -Force | Out-Null + + $project = Join-Path $targetBenchmarks "CodeBeam.MudBlazor.Extensions.Benchmarks/CodeBeam.MudBlazor.Extensions.Benchmarks.csproj" + $probeOutput = Join-Path $variantResults "scale-probe.csv" + $bdnArtifacts = Join-Path $variantResults "BenchmarkDotNet.Artifacts" + + $env:CI = "true" + $env:BENCHMARK_VARIANT = $name + + (& dotnet --info) | Out-File -FilePath (Join-Path $variantResults "dotnet-info.txt") -Encoding utf8 + (& git -C $worktree show -s --format="%H%n%ad%n%s" --date=iso-strict HEAD) | + Out-File -FilePath (Join-Path $variantResults "source.txt") -Encoding utf8 + + if (-not $SkipProbe) { + Invoke-Checked dotnet @( + "run", "--project", $project, "--configuration", "Release", "--", + "probe", "--output", $probeOutput + ) + } + + if ($ProbeOnly) { + return + } + + $benchmarkArguments = [System.Collections.Generic.List[string]]::new() + @( + "run", "--project", $project, "--configuration", "Release", "--", + "--filter", $BenchmarkFilter, + "--artifacts", $bdnArtifacts, + "--exporters", "GitHub", "CSV", "JSON", + "--allStats", + "--join" + ) | ForEach-Object { $benchmarkArguments.Add($_) } + + if ($Quick) { + $benchmarkArguments.Add("--job") + $benchmarkArguments.Add("short") + } + + Invoke-Checked dotnet $benchmarkArguments.ToArray() +} + +function Format-SummaryBytes([double]$bytes) { + $absolute = [Math]::Abs($bytes) + if ($absolute -ge 1GB) { return "{0:F2} GB" -f ($bytes / 1GB) } + if ($absolute -ge 1MB) { return "{0:F2} MB" -f ($bytes / 1MB) } + if ($absolute -ge 1KB) { return "{0:F1} KB" -f ($bytes / 1KB) } + return "{0:F0} B" -f $bytes +} + +function Get-ProbeRow([string]$variant, [int]$itemCount, [int]$selectCount, [int]$selectedCount, [bool]$virtualize) { + $path = Join-Path $ResultsDirectory "$variant/scale-probe.csv" + if (-not (Test-Path $path)) { + return $null + } + + return Import-Csv $path | + Where-Object { + [int]$_.ItemCount -eq $itemCount -and + [int]$_.SelectCount -eq $selectCount -and + [int]$_.SelectedCount -eq $selectedCount -and + [bool]::Parse($_.Virtualize) -eq $virtualize + } | + Select-Object -First 1 +} + +function Get-ProbeValue($row, [string]$property) { + if ($null -eq $row) { + return $null + } + + return [double]$row.PSObject.Properties[$property].Value +} + +function Format-ProbeValue([string]$property, $value) { + if ($null -eq $value) { + return "—" + } + + switch ($property) { + "ElapsedMilliseconds" { return "{0:F2} ms" -f $value } + "AllocatedBytes" { return Format-SummaryBytes $value } + "ApproxRetainedBytes" { return Format-SummaryBytes $value } + default { return "{0:N0}" -f $value } + } +} + +function Format-Reduction($baselineValue, $fixedValue) { + if ($null -eq $baselineValue -or $null -eq $fixedValue -or $fixedValue -le 0) { + return "—" + } + + $factor = $baselineValue / $fixedValue + if ($factor -ge 1) { + return "{0:F1}x lower" -f $factor + } + + return "{0:F1}x higher" -f (1 / $factor) +} + +function Write-ProbeComparisonSummary { + if ($SkipProbe) { + return + } + + $scenarios = @( + @{ Name = "4,000 items x 1 select"; Items = 4000; Selects = 1; Selected = 2; Virtualize = $true }, + @{ Name = "4,000 items x 5 selects"; Items = 4000; Selects = 5; Selected = 2; Virtualize = $true }, + @{ Name = "4,000 items x 20 selects"; Items = 4000; Selects = 20; Selected = 2; Virtualize = $true }, + @{ Name = "4,000 items, 100 selected"; Items = 4000; Selects = 1; Selected = 100; Virtualize = $true } + ) + + $metrics = @( + @{ Name = "Materialized components"; Property = "SelectItemComponentCount" }, + @{ Name = "Allocated"; Property = "AllocatedBytes" }, + @{ Name = "Approx. retained"; Property = "ApproxRetainedBytes" }, + @{ Name = "One-shot elapsed"; Property = "ElapsedMilliseconds" } + ) + + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add("# Virtualized selection comparison") + $lines.Add("") + $lines.Add("Generated from the same benchmark harness against immutable Git commits.") + $lines.Add("") + $lines.Add("- Upstream baseline: `$baselineSha` (`$BaselineRef`)") + $lines.Add("- PR1 candidate: `$fixedSha` (`$FixedRef`)") + $lines.Add("") + $lines.Add("Probe elapsed/retained values are diagnostic. Shared-runner timing should be treated as illustrative; structural component counts and allocation/scaling shape are the stronger CI evidence.") + $lines.Add("") + $lines.Add("| Scenario | Metric | Upstream baseline | PR1 candidate | Baseline -> PR1 |") + $lines.Add("| --- | --- | ---: | ---: | ---: |") + + foreach ($scenario in $scenarios) { + $baselineRow = Get-ProbeRow "baseline" $scenario.Items $scenario.Selects $scenario.Selected $scenario.Virtualize + $fixedRow = Get-ProbeRow "fixed" $scenario.Items $scenario.Selects $scenario.Selected $scenario.Virtualize + + foreach ($metric in $metrics) { + $baselineValue = Get-ProbeValue $baselineRow $metric.Property + $fixedValue = Get-ProbeValue $fixedRow $metric.Property + $lines.Add("| $($scenario.Name) | $($metric.Name) | $(Format-ProbeValue $metric.Property $baselineValue) | $(Format-ProbeValue $metric.Property $fixedValue) | $(Format-Reduction $baselineValue $fixedValue) |") + } + } + + $summaryPath = Join-Path $ResultsDirectory "comparison-summary.md" + $lines | Out-File -FilePath $summaryPath -Encoding utf8 + Write-Host "Wrote $summaryPath" +} + +try { + @( + "Baseline ref: $BaselineRef", + "Baseline SHA: $baselineSha", + "PR1 ref: $FixedRef", + "PR1 SHA: $fixedSha", + "Started: $([DateTimeOffset]::Now.ToString('O'))", + "Benchmark filter: $BenchmarkFilter", + "Quick: $Quick", + "Probe only: $ProbeOnly", + "Skip probe: $SkipProbe" + ) | Out-File -FilePath (Join-Path $ResultsDirectory "run-info.txt") -Encoding utf8 + + Invoke-Variant "baseline" $baselineSha + Invoke-Variant "fixed" $fixedSha + Write-ProbeComparisonSummary + + Write-Host "`nBenchmark results: $ResultsDirectory" -ForegroundColor Green +} +finally { + $env:CI = $previousCi + $env:BENCHMARK_VARIANT = $previousVariant + + foreach ($worktree in $worktrees) { + if (Test-Path $worktree) { + & git -C $repoRoot worktree remove --force $worktree | Out-Null + } + } + + & git -C $repoRoot worktree prune | Out-Null + if (Test-Path $workRoot) { + Remove-Item -Path $workRoot -Recurse -Force + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 5a570a83..38ab3006 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ services: mudextensions: - build: - context: . + image: ghcr.io/codebeamorg/codebeam.mudblazor.extensions:dev container_name: mudextensions restart: unless-stopped networks: diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml b/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml index c79cee8a..b920721f 100644 --- a/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml @@ -1310,6 +1310,11 @@ Represents a base class for designing date picker components. + + + The unique identifier used to associate picker elements. + + Is set to true to scroll to the actual year after the next render @@ -1387,7 +1392,7 @@ The current month shown in the date picker. - Defaults to the current month.
+ Defaults to the current month. When bound via @bind-PickerMonth, controls the initial month displayed. This value is always the first day of a month.
@@ -1401,7 +1406,7 @@ The delay, in milliseconds, before closing the picker after a value is selected. - Defaults to 100.
+ Defaults to 100. This delay helps the user see that a date has been selected before the popover disappears.
@@ -1418,7 +1423,7 @@ The maximum number of months allowed in one row. - Defaults to null.
+ Defaults to null. When null, the is used.
@@ -1440,7 +1445,7 @@ The format of the selected date in the title. - Defaults to ddd, dd MMM.
+ Defaults to ddd, dd MMM. Supported date formats can be found here: .
@@ -1457,7 +1462,7 @@ The function used to disable one or more dates. - Defaults to null.
+ Defaults to null. When set, a date will be disabled if the function returns true.
@@ -1514,6 +1519,13 @@ True if the generic type T is DateOnly or nullable, false otherwise. + + + Determines whether a non-nullable picker value is its default value. + + The value to evaluate. + when is non-nullable and equals its default value; otherwise, . + Generic conversion method to convert the generic type T to DateTime. Supports DateTime and DateTimeOffset. @@ -1534,12 +1546,24 @@ if is . Thrown if is not or . + + + Validates that is a supported date type. + + + + + Handles a change to the configured date format. + + The newly configured date format. + A task that represents the asynchronous operation. + True if the current view is either hours or minutes, false otherwise. @@ -1550,6 +1574,72 @@ True if the current view is either date, month or year, false otherwise. + + + + + + Gets the first day of the month at the specified offset from the displayed month. + + The zero-based offset from the displayed month. + The first day of the requested month. + + + + Gets the seven days displayed for a calendar week. + + The zero-based offset from the displayed month. + The zero-based week index. + The dates in the requested week. + + + + Determines whether a date is unavailable for selection. + + The date to evaluate. + when the date is disabled; otherwise, . + + + + Gets the CSS classes for a calendar day. + + The zero-based offset from the displayed month. + The day to format. + The CSS classes for the day. + + + + Handles selection of a calendar day. + + The selected date and time. + A task that represents the asynchronous operation. + + + + Formats a date for the picker title. + + The date to format. + The formatted title, or an empty string when is . + + + + Gets abbreviated day names ordered according to the configured first day of the week. + + The ordered abbreviated day names. + + + + + + + + + + Converts a picker value to its display text. + + The value to convert. + The formatted text, or when the value has no date representation. + Returns the date and time format string to use for formatting operations. @@ -1561,6 +1651,32 @@ otherwise, a default pattern based on the current culture's short date pattern and a 24-hour time format is used. + + + Gets the initial month shown by the calendar. + + The first day of the initial month. + + + + Gets the culture-specific calendar year for a date. + + The date whose calendar year is required. + The calendar year. + + + + Gets the first day of the calendar week. + + The configured first day of the week, or the culture default. + + + + Gets the last day of the month at the specified offset from the displayed month. + + The zero-based offset from the displayed month. + The last day of the requested month. + Scrolls to the current year. @@ -1572,6 +1688,31 @@ + + + Initializes a new instance of the class. + + + + + Gets or sets the selected hour. + + + + + Gets or sets the selected minute. + + + + + Gets or sets whether the clock pointer is currently being dragged. + + + + + Gets the element reference for the clock surface. + + @@ -1581,6 +1722,11 @@ + + + The active picker mode. + + The currently selected value. @@ -1599,9 +1745,9 @@ Shows a 12-hour selection clock. - Defaults to false.
- When true, hours 1-12 are displayed with an AM or PM marker.
- When false, hours 0-23 are displayed.
+ Defaults to false. + When true, hours 1-12 are displayed with an AM or PM marker. + When false, hours 0-23 are displayed.
@@ -1631,6 +1777,20 @@ Gets or sets the text displayed for the post-meridiem (PM) indicator.
+ + + + + + Sets the selected date and optionally updates the input text and bound value. + + The date to set, or to clear the selection. + Whether to update the displayed text and reset conversion errors. + A task that represents the asynchronous operation. + + + + Handles the event when a day is clicked in the calendar view. This method updates the working value with the selected date, and if appropriate based on the component's configuration, submits the new value and closes the picker. @@ -1649,7 +1809,7 @@ - Clears the selected date and time, resetting the component to its initial state. If is true, the picker will also close after clearing the value. + Clears the selected date and time, resetting the component to its initial state. If AutoClose is true, the picker will also close after clearing the value. Indicates whether the picker should close after clearing the value. A task that represents the asynchronous operation. @@ -1660,6 +1820,15 @@ The formatted date string for the title of the picker. + + + Gets the date used in the picker header. + + + A non-nullable date picker uses its default value as its cleared value. The header uses the current local date + as the picker reference date for that sentinel instead of presenting it as a selected value. + + Calculates the first day of the month for the current calendar context. @@ -1680,6 +1849,158 @@ current culture. The calendar year as an integer, adjusted based on the current value and the specified date. + + + Gets the localized name of a displayed month. + + The zero-based offset from the displayed month. + The localized month name and year. + + + + Moves the calendar to the previous month. + + A completed task. + + + + Moves the calendar to the next month. + + A completed task. + + + + Gets the earliest selectable year. + + The year from MinDate, or 1900 when no minimum is set. + + + + Gets the latest selectable year. + + The year from MaxDate, or 2100 when no maximum is set. + + + + Handles selection of a year in the calendar. + + The selected year. + A task that represents the asynchronous operation. + + + + Gets the typography style for a year. + + The year to evaluate. + The typography style for the year. + + + + Gets the CSS classes for a year. + + The year to evaluate. + The CSS classes for the year. + + + + Moves the calendar to the previous year. + + A completed task. + + + + Moves the calendar to the next year. + + A completed task. + + + + Gets all month numbers used by the month-selection view. + + The month numbers from 1 through 12. + + + + Handles selection of a month in the calendar. + + The selected month number. + A task that represents the asynchronous operation. + + + + Determines whether every day in a month is outside the configured date range. + + The month number to evaluate. + when the month is disabled; otherwise, . + + + + Gets the typography style for a month. + + The month number to evaluate. + The typography style for the month. + + + + Gets the CSS classes for a month. + + The month number to evaluate. + The CSS classes for the month. + + + + Gets the localized abbreviated name of a month. + + The month number. + The abbreviated month name. + + + + Gets the culture-specific week number for a displayed calendar week. + + The zero-based offset from the displayed month. + The zero-based week index. + The week number. + + + + Gets the day-of-month text for a calendar date. + + The date to format. + The localized day-of-month text. + + + + Switches the picker to month selection. + + + + + Switches the picker to month selection. + + The clicked month number. + + + + Updates the working time from a clock-pointer movement. + + The hour or minute indicated by the clock pointer. + Whether the pointer is currently being dragged. + A task that represents the asynchronous operation. + + + + Handles a click on the clock pointer and advances or submits the time selection. + + The selected hour or minute. + A task that represents the asynchronous operation. + + + + Submits the working value and closes the picker when its configuration permits it. + + A task that represents the asynchronous operation. + Gets the hour portion of the selected time. @@ -1692,11 +2013,20 @@ A two-digit string for minutes, or -- if no value is set. + + + Gets the year displayed in the picker toolbar. + + The formatted year. + Scrolls to the current year. + + + Sets the current view of the picker to the specified value. @@ -3319,6 +3649,13 @@ + + + Re-applies selection state whenever a list item receives parameters. + Virtualized lists can reuse an existing component instance for a different value, + so selection cannot rely only on registration/scroll timing. + + @@ -3552,6 +3889,199 @@ The child content. + + + Represents a confirmed default value for a target header when no source item is mapped. + + + + + The default value to use. + + + + + Whether the default value has been confirmed by the user. + + + + + Represents a target field that source items can be mapped onto. + + + + + CSS applied to the drop zone when this required header has no match. + + + + + The name of the target header. + + + + + Aliases for the header. If any alias matches a source item name it is treated as a match. + + + + + Whether this header must be mapped before the user can confirm. + + + + + Whether the user may supply a default value instead of mapping a source item. + + + + + Internal UI state: whether the default-value entry form is expanded. + + + + + Number of source items currently mapped to this header. + + + + Initializes a new instance. + + + Initializes a new instance with the given name. + + + Initializes a new instance with the given name and required flag. + + + Initializes a new instance with name, required and allowDefaultValue flags. + + + Initializes a new instance with all properties. + + + + Represents a source item that can be dragged onto a target header zone. + + + + + The display name of the source item. + + + + + The identifier of the drop zone this item currently occupies. + Defaults to "Source" (the unassigned pool). + + + + Initializes a new instance. + Display name. + Initial zone identifier; defaults to "Source". + + + + A standalone drag-and-drop field mapper component. + Allows users to map source items onto target headers without any file or CSV dependency. + + + + + The identifier for the drop zone that contains unassigned source items. + + + + + CSS class for the root element. + + + + + Localized display strings for the component. + + + + + The list of target headers that source items can be mapped onto. + + + + + The source items available for mapping. Mutated in-place as the user drags items. + + + + + Whether the user may create new target headers at runtime. + + + + + Whether to show the "Include unmapped data" toggle. + + + + + Label for the confirm action button. Defaults to the value in . + + + + + Icon for the confirm action button. + + + + + Fires when the user clicks the confirm button and the mapping is valid. + + + + + Fires when the user clicks the reset button. + + + + + Whether the user has already confirmed the mapping. + Controls whether the confirm button or the reset button is displayed. + + + + + Whether the current mapping satisfies all required target headers. + + + + + Whether source items that are not mapped to any target should be included in the output. + Readable after the user confirms. + + + + + The confirmed default values keyed by target header name. + Readable after the user confirms. + + + + + + + + + + + Determines whether a given source item is currently in the unassigned source pool. + + The source item to check. + True if the item is in the unassigned source pool; otherwise, false. + + + + Resets mapping state so the component can be reused for a new set of source items. + + @@ -4676,6 +5206,13 @@ A dictionary containing only ARIA attributes + + + Returns the values that must be materialized by the hidden list. + Non-virtualized selects preserve the existing full item registry behavior; + virtualized selects only materialize selected values that exist in the current item collection. + + Represents an option of a select or multi-select. To be used inside MudSelect. @@ -7189,6 +7726,11 @@ + + + Localized strings for MudMapper. + + diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/CodeBeam.MudBlazor.Extensions.Docs.csproj b/docs/CodeBeam.MudBlazor.Extensions.Docs/CodeBeam.MudBlazor.Extensions.Docs.csproj index 73cee5f9..0eb74754 100644 --- a/docs/CodeBeam.MudBlazor.Extensions.Docs/CodeBeam.MudBlazor.Extensions.Docs.csproj +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/CodeBeam.MudBlazor.Extensions.Docs.csproj @@ -37,4 +37,5 @@ + diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/DateTimePickerPage.razor b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/DateTimePickerPage.razor index a2daa74d..abe7efff 100644 --- a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/DateTimePickerPage.razor +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/DateTimePickerPage.razor @@ -5,7 +5,7 @@ - + diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample2.razor b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample2.razor index e599abe7..2b8a39bf 100644 --- a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample2.razor +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample2.razor @@ -3,11 +3,25 @@ - - - - + + + + + + + + + + + @@ -19,9 +33,14 @@ @code { - private DateTime? _date = DateTime.Now; - private DateTimeOffset _date2 = DateTimeOffset.UtcNow; - private DateOnly _date3 = DateOnly.FromDateTime(DateTime.Now); + private static readonly TimeZoneInfo IstanbulTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul"); + private DateTime _dateTime = DateTime.Now; + private DateTime? _nullableDateTime; + private DateTimeOffset _dateTimeOffset = DateTimeOffset.UtcNow; + private DateTimeOffset _istanbulDateTimeOffset = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, IstanbulTimeZone); + private DateTimeOffset? _nullableDateTimeOffset; + private DateOnly _dateOnly = DateOnly.FromDateTime(DateTime.Now); + private DateOnly? _nullableDateOnly; private bool _clearable = false; private bool _editable = false; } diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample3.razor b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample3.razor index 2e0ca6d7..8e78683d 100644 --- a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample3.razor +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample3.razor @@ -3,14 +3,61 @@ - - - Clear - Cancel - OK - - + + + + Clear + Cancel + OK + + + + + + Clear + Cancel + OK + + + + + + Clear + Cancel + OK + + + + + + Clear + Cancel + OK + + + + + + Clear + Cancel + OK + + + + + + Clear + Cancel + OK + + + @@ -32,15 +79,25 @@ } - Set Today + Set Current Values @code { - private MudDateTimePicker _picker = null!; + private MudDateTimePicker _dateTimePicker = null!; + private MudDateTimePicker _nullableDateTimePicker = null!; + private MudDateTimePicker _dateTimeOffsetPicker = null!; + private MudDateTimePicker _nullableDateTimeOffsetPicker = null!; + private MudDateTimePicker _dateOnlyPicker = null!; + private MudDateTimePicker _nullableDateOnlyPicker = null!; - private DateTime? _date = DateTime.Now; + private DateTime _dateTime = DateTime.Now; + private DateTime? _nullableDateTime; + private DateTimeOffset _dateTimeOffset = DateTimeOffset.UtcNow; + private DateTimeOffset? _nullableDateTimeOffset; + private DateOnly _dateOnly = DateOnly.FromDateTime(DateTime.Now); + private DateOnly? _nullableDateOnly; private bool _editable = false; private bool _showToolbar = true; private bool _submitOnClose = true; @@ -49,4 +106,16 @@ private bool _isAdornmentEnd = true; private bool _clearable = false; private Variant _variant = Variant.Outlined; + + private void SetCurrentValues() + { + _dateTime = DateTime.Now; + _nullableDateTime = DateTime.Now; + _dateTimeOffset = DateTimeOffset.UtcNow; + _nullableDateTimeOffset = DateTimeOffset.UtcNow; + _dateOnly = DateOnly.FromDateTime(DateTime.Now); + _nullableDateOnly = DateOnly.FromDateTime(DateTime.Now); + } } + + \ No newline at end of file diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/Examples/MapperExample1.razor b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/Examples/MapperExample1.razor new file mode 100644 index 00000000..a769b1f8 --- /dev/null +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/Examples/MapperExample1.razor @@ -0,0 +1,78 @@ +@namespace MudExtensions.Docs.Examples +@inject ISnackbar Snackbar +@using MudExtensions.Utilities + + + + + Drag the source columns onto the matching target fields. Email is required. + + + + + + @if (_mappingSummary.Count > 0) + { + + + Mapping Result + @foreach (var (target, source) in _mappingSummary) + { + @source → @target + } + + + } + + +@code { + MudMapper? _mapper; + List<(string Target, string Source)> _mappingSummary = new(); + + List _sourceItems = + [ + new("email_addr"), + new("first_name"), + new("last_name"), + new("phone_num"), + new("zip_code"), + ]; + + List _targetHeaders = + [ + new("Email", required: true), + new("First Name"), + new("Last Name"), + new("Phone"), + ]; + + private void OnMappingConfirmed() + { + _mappingSummary = _mapper?.SourceItems + .Where(i => i.MappedZone != "Source") + .Select(i => (i.MappedZone, i.Name)) + .ToList() ?? []; + + Snackbar.Add("Mapping confirmed!", Severity.Success); + } + + private void OnReset() + { + _mappingSummary.Clear(); + // Re-initialise source items so chips return to the source pool + _sourceItems = + [ + new("email_addr"), + new("first_name"), + new("last_name"), + new("phone_num"), + new("zip_code"), + ]; + _targetHeaders.ForEach(h => h.MatchedFieldCount = 0); + } +} diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/Examples/MapperExample2.razor b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/Examples/MapperExample2.razor new file mode 100644 index 00000000..2b124876 --- /dev/null +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/Examples/MapperExample2.razor @@ -0,0 +1,82 @@ +@namespace MudExtensions.Docs.Examples +@inject ISnackbar Snackbar +@using MudExtensions.Utilities + + + + + Use Create Header to add new target headers on the fly. + Toggle Include unmapped data to keep source items that have no target. + + + + + + @if (_confirmed) + { + + + + Result — Include unmapped: @(_mapper?.IncludeUnmappedData) + + @foreach (var item in _mapper?.SourceItems ?? []) + { + + @item.Name → @(item.MappedZone == "Source" ? "(unmapped)" : item.MappedZone) + + } + + + } + + +@code { + MudMapper? _mapper; + bool _confirmed; + + List _sourceItems = + [ + new("product_id"), + new("product_name"), + new("unit_price"), + new("qty_in_stock"), + new("sku_code"), + new("category"), + ]; + + List _targetHeaders = + [ + new("Id", required: true), + new("Name", required: true), + new("Price"), + new("Stock"), + ]; + + private void OnConfirmed() + { + _confirmed = true; + Snackbar.Add("Mapping applied!", Severity.Info); + } + + private void OnReset() + { + _confirmed = false; + _sourceItems = + [ + new("product_id"), + new("product_name"), + new("unit_price"), + new("qty_in_stock"), + new("sku_code"), + new("category"), + ]; + _targetHeaders.ForEach(h => h.MatchedFieldCount = 0); + } +} diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/Examples/MapperExample3.razor b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/Examples/MapperExample3.razor new file mode 100644 index 00000000..672cbd57 --- /dev/null +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/Examples/MapperExample3.razor @@ -0,0 +1,75 @@ +@namespace MudExtensions.Docs.Examples +@inject ISnackbar Snackbar +@using MudExtensions.Utilities + + + + + Required headers marked with AllowDefaultValue show a + button. + Click it to enter a fallback value when no source item is available to map. + + + + + + @if (_confirmed) + { + + + Confirmed Mapping + @foreach (var item in _mapper?.SourceItems.Where(i => i.MappedZone != "Source") ?? []) + { + @item.Name → @item.MappedZone + } + @foreach (var kv in _mapper?.DefaultValues?.Where(d => d.Value.Confirmed) ?? []) + { + + (default) "@kv.Value.DefaultValue" → @kv.Key + + } + + + } + + +@code { + MudMapper? _mapper; + bool _confirmed; + + // Intentionally fewer source items than target headers to demonstrate defaults + List _sourceItems = + [ + new("emp_name"), + new("emp_email"), + ]; + + List _targetHeaders = + [ + new("Name", required: true), + new("Email", required: true), + new("Department", required: true, allowDefaultValue: true), + new("Start Date", required: false, allowDefaultValue: true), + ]; + + private void OnConfirmed() + { + _confirmed = true; + Snackbar.Add("Mapping saved!", Severity.Success); + } + + private void OnReset() + { + _confirmed = false; + _sourceItems = + [ + new("emp_name"), + new("emp_email"), + ]; + _targetHeaders.ForEach(h => h.MatchedFieldCount = 0); + } +} diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/MapperPage.razor b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/MapperPage.razor new file mode 100644 index 00000000..0d32bd3d --- /dev/null +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/Mapper/MapperPage.razor @@ -0,0 +1,19 @@ +@page "/mudmapper" +@namespace MudExtensions.Docs.Pages + + + + + + + + + + + + + + diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/SelectExtended/Examples/SelectExtendedExample9.razor b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/SelectExtended/Examples/SelectExtendedExample9.razor index db6fff9e..b9a5c82d 100644 --- a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/SelectExtended/Examples/SelectExtendedExample9.razor +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/SelectExtended/Examples/SelectExtendedExample9.razor @@ -1,29 +1,45 @@ -@namespace MudExtensions.Docs.Examples +@namespace MudExtensions.Docs.Examples - - - - - - - - + + + Both selects offer 1,000 options, but virtualization renders only the visible options. + Values near the end of the collection are already selected. + + + + + + + + Selected items: @(string.Join(", ", _selectedValues ?? [])) + - - - - + @code { - private bool _multiselection = true; - private bool _clearable = false; - private string[] _collection = new string[] { "Foo", "Bar", "Fizz", "Buzz" }; - private IEnumerable _values = new List() { "Foo" }; -} \ No newline at end of file + private readonly string[] _items = Enumerable.Range(1, 1_000) + .Select(index => $"Item {index:0000}") + .ToArray(); + + private string? _selectedValue = "Item 0950"; + private IEnumerable _selectedValues = ["Item 0005", "Item 0950"]; + private bool _clearable; +} diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Services/MudExtensionsDocsService.cs b/docs/CodeBeam.MudBlazor.Extensions.Docs/Services/MudExtensionsDocsService.cs index ca3254ab..cc8826b5 100644 --- a/docs/CodeBeam.MudBlazor.Extensions.Docs/Services/MudExtensionsDocsService.cs +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Services/MudExtensionsDocsService.cs @@ -23,6 +23,7 @@ public class MudExtensionsDocsService new MudExtensionComponentInfo() {Title = "MudListExtended", Component = typeof(MudListExtended<>), Usage = ComponentUsage.Input, RelatedComponents = new List() {typeof(MudListItemExtended)}, IsUnique = false, Description = "The extended MudList component with richer features."}, new MudExtensionComponentInfo() {Title = "MudLoading", Component = typeof(MudLoading), Usage = ComponentUsage.Display, IsUnique = true, Description = "Loading container for a whole page or a specific section."}, new MudExtensionComponentInfo() {Title = "MudLoadingButton", Component = typeof(MudLoadingButton), Usage = ComponentUsage.Button, IsUnique = true, Description = "A classic MudButton with loading improvements."}, + new MudExtensionComponentInfo() {Title = "MudMapper", Component = typeof(MudMapper), Usage = ComponentUsage.Input, IsUnique = true, Description = "A standalone drag-and-drop field mapper. Map source items onto target headers without any file or CSV dependency."}, new MudExtensionComponentInfo() {Title = "MudPage", Component = typeof(MudPage), Usage = ComponentUsage.Layout, RelatedComponents = new List() {typeof(MudSection)}, IsUnique = true, Description = "A CSS grid layout component that builds columns and rows, supports ColSpan & RowSpan."}, new MudExtensionComponentInfo() {Title = "MudPasswordField", Component = typeof(MudPasswordField<>), Usage = ComponentUsage.Input, IsUnique = true, Description = "A specialized textfield that designed for working easily with passwords."}, new MudExtensionComponentInfo() {Title = "MudPopup", Component = typeof(MudPopup), Usage = ComponentUsage.Display, IsUnique = true, Description = "A mobile friendly multi-functional popup content."}, diff --git a/src/CodeBeam.MudBlazor.Extensions.CsvMapper/CodeBeam.MudBlazor.Extensions.CsvMapper.csproj b/src/CodeBeam.MudBlazor.Extensions.CsvMapper/CodeBeam.MudBlazor.Extensions.CsvMapper.csproj index d1c29979..068075cf 100644 --- a/src/CodeBeam.MudBlazor.Extensions.CsvMapper/CodeBeam.MudBlazor.Extensions.CsvMapper.csproj +++ b/src/CodeBeam.MudBlazor.Extensions.CsvMapper/CodeBeam.MudBlazor.Extensions.CsvMapper.csproj @@ -24,7 +24,7 @@ - + diff --git a/src/CodeBeam.MudBlazor.Extensions.CsvMapper/Components/CsvMapper/MudCsvMapper.razor b/src/CodeBeam.MudBlazor.Extensions.CsvMapper/Components/CsvMapper/MudCsvMapper.razor index 177be133..94e3b7b6 100644 --- a/src/CodeBeam.MudBlazor.Extensions.CsvMapper/Components/CsvMapper/MudCsvMapper.razor +++ b/src/CodeBeam.MudBlazor.Extensions.CsvMapper/Components/CsvMapper/MudCsvMapper.razor @@ -1,10 +1,10 @@ -@namespace MudExtensions +@namespace MudExtensions @inherits MudComponentBase @using Microsoft.AspNetCore.Components -@using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Components.Forms +
- - - - @if (MudCsvHeaders.Count > 0) - { -

CSV File Headers

-
- - -
- } -

@LocalizedStrings.ExpectedHeaders

-
- @if (ExpectedHeaders.Count == 0) - { - @LocalizedStrings.DefineHeaders - } - - @foreach (var item in ExpectedHeaders) - { - var required = item.Required; - var matched = item.MatchedFieldCount > 0; - bool confirmed = _defaultValueHeaders?.ContainsKey(item.Name ?? string.Empty) == true && _defaultValueHeaders[item.Name ?? string.Empty].Confirmed; - string? warning; - if (item.AllowDefaultValue) - { - warning = "This field is required. You must either provide a default value or map a field from the imported csv."; - } - else - { - warning = "This field is required. You must map a field from the imported csv."; - } - - - - - @item.Name - @if (required) - { - * - } - @if (!matched && item.CreatingDefaultValue) - { - - } - else if(!matched && item.AllowDefaultValue) - { - - } - - @if (!matched && !confirmed) - { - - @LocalizedStrings.DragHere - - } - @if (!matched && item.CreatingDefaultValue) - { - - @(_defaultValueHeaders[item.Name ?? string.Empty].Confirmed ? "Edit" : "Confirm") - } - - - - } - - - @if (AllowCreateExpectedHeaders && _addSectionOpen) - { - - - - - Required - Allow Default Value - Add Header - - - } - else if(AllowCreateExpectedHeaders) - { - Create Header - } - -
- -
- - @context.Name - + -
- @if (ShowIncludeUnmappedData) - { - Include unmapped data - } - @if (!_importedComplete) - { - @LocalizedStrings.Import - } - else - { - Reset - }
diff --git a/src/CodeBeam.MudBlazor.Extensions.CsvMapper/Components/CsvMapper/MudCsvMapper.razor.cs b/src/CodeBeam.MudBlazor.Extensions.CsvMapper/Components/CsvMapper/MudCsvMapper.razor.cs index ecd31eba..129d7195 100644 --- a/src/CodeBeam.MudBlazor.Extensions.CsvMapper/Components/CsvMapper/MudCsvMapper.razor.cs +++ b/src/CodeBeam.MudBlazor.Extensions.CsvMapper/Components/CsvMapper/MudCsvMapper.razor.cs @@ -1,4 +1,5 @@ -using CsvHelper; +using CsvHelper; +using CsvHelper.Configuration; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; using MudBlazor; @@ -6,256 +7,115 @@ using MudExtensions.Utilities; using System.Globalization; using System.Text; -using CsvHelper.Configuration; namespace MudExtensions { /// - /// + /// Backward-compatibility alias for . /// - public class ConfirmedDefaultValue + [Obsolete("MudCsvHeader has been renamed to MudMapperItem. Please update your code to use MudMapperItem and its MappedZone property instead of MappedField.")] + public class MudCsvHeader : MudMapperItem { - /// - /// - /// - public string? DefaultValue { get; set; } - - /// - /// - /// - public bool Confirmed { get; set; } + /// + public MudCsvHeader(string? name, string? mappedField = "Source") + : base(name, mappedField) { } } /// - /// Default fields in your database - /// - public class MudExpectedHeader - { - /// - /// - /// - public readonly string? RequiredCss = "border-color: var(--mud-palette-error); color: var(--mud-palette-error);"; - - /// - /// - /// - public string? Name { get; set; } = ""; - - /// - /// Aliases for the expected header. If any of the aliases match a CSV header, it will be considered a match. - /// - public IEnumerable? Aliases { get; set; } = null; - - /// - /// - /// - public bool Required { get; set; } - - /// - /// - /// - public bool AllowDefaultValue { get; set; } - - /// - /// - /// - public bool CreatingDefaultValue { get; set; } - - /// - /// - /// - public int MatchedFieldCount { get; set; } = 0; - - /// - /// - /// - public MudExpectedHeader() - { - } - - /// - /// - /// - /// - public MudExpectedHeader(string? name) - { - Name = name; - Required = false; - } - - /// - /// - /// - /// - /// - public MudExpectedHeader(string? name, bool required = false) - { - Name = name; - Required = required; - } - - /// - /// - /// - /// - /// - /// - public MudExpectedHeader(string? name, bool required = false, bool allowDefaultValue = false) - { - Name = name; - Required = required; - AllowDefaultValue = allowDefaultValue; - } - - /// - /// - /// - /// - /// - /// - /// - public MudExpectedHeader(string? name, bool required = false, bool allowDefaultValue = false, IEnumerable? aliases = null) - { - Name = name; - Required = required; - AllowDefaultValue = allowDefaultValue; - Aliases = aliases; - } - } - - /// - /// Header fields in your CSV File - /// - public class MudCsvHeader - { - /// - /// - /// - public string Name { get; set; } = ""; - - /// - /// - /// - public string MappedField { get; set; } = "File"; - - /// - /// - /// - /// - /// - public MudCsvHeader(string? name, string? mappedField = "File") - { - Name = name ?? ""; - MappedField = mappedField ?? "File"; - } - } - - /// - /// + /// A component that combines a CSV file upload with a to let users + /// map CSV columns onto expected target headers and produce a re-mapped CSV output. /// public partial class MudCsvMapper : MudComponentBase { - /// - /// - /// + /// CSS class for the root element. protected string? Classname => new CssBuilder("mud-csv-mapper") .AddClass(Class) .Build(); /// - /// A class for provide all local strings at once. + /// A class to provide all localized strings at once. /// [Parameter] public CsvMapperLocalizedStrings LocalizedStrings { get; set; } = new(); /// - /// Choose Table Column Headers + /// The expected target headers that CSV columns should be mapped onto. /// [Parameter] public List ExpectedHeaders { get; set; } = new(); - private bool _valid = false; - /// - /// CsvFile as BrowserFile + /// The uploaded CSV file as a browser file reference. /// [Parameter] public IBrowserFile? CsvFile { get; set; } = null; /// - /// + /// The raw bytes of the (re-mapped) CSV file after import. /// [Parameter] public byte[]? FileContentByte { get; set; } /// - /// Use this dictionary if you want to see what was mapped. + /// A dictionary of the mappings that were applied: key = target header name, value = original CSV column name. /// [Parameter] public Dictionary CsvMapping { get; set; } = new(); /// - /// + /// Fires when the CSV has been successfully imported and re-mapped. /// [Parameter] public EventCallback OnImported { get; set; } /// - /// + /// Whether to show the "Include unmapped data" toggle inside the mapper. /// - [Parameter] + [Parameter] public bool ShowIncludeUnmappedData { get; set; } /// - /// + /// Whether the user may create new target headers at runtime. /// [Parameter] public bool AllowCreateExpectedHeaders { get; set; } /// - /// + /// When true, header names are normalised (lowercased, spaces and quotes stripped) + /// before being written to the output CSV. /// [Parameter] public bool NormalizeHeaders { get; set; } /// - /// + /// The column delimiter used when reading and writing the CSV file. Defaults to ",". /// [Parameter] public string Delimiter { get; set; } = ","; - [Inject] private IDialogService? _dialogService { get; set; } [Inject] private NavigationManager? _navigationManager { get; set; } + private MudMapper? _mapper; + + private MudMapperLocalizedStrings _mapperLocalizedStrings => new() + { + SourceItems = "CSV File Headers", + TargetHeaders = LocalizedStrings.ExpectedHeaders, + DragHere = LocalizedStrings.DragHere, + DefineHeaders = LocalizedStrings.DefineHeaders + }; + private string DragClass = DefaultDragClass; private static readonly string DefaultDragClass = "relative rounded-lg border-2 border-dashed pa-4 mt-4 mud-width-full mud-height-full z-10"; - private readonly string _requiredDefaultValueMessage = "Default value is required if no header is mapped"; - private readonly string _expectedHeaderDropZoneWidth = "width: 180px;"; - private List FileNames = new (); - private List MudCsvHeaders = new(); - private List>? CsvContent; - private bool _includeUnmappedData; - private bool _importedComplete; - private MudExpectedHeader _model { get; set; } = new(); - private bool _addSectionOpen; - private Dictionary? _defaultValueHeaders { get; set; } + private List FileNames = new(); + private List _sourceItems = new(); + private List>? CsvContent; - /// - /// - /// - protected override void OnInitialized() - { - _defaultValueHeaders = ExpectedHeaders.Where(x => x.AllowDefaultValue).ToDictionary(key => key.Name ?? "", val => new ConfirmedDefaultValue() - { - Confirmed = false, - DefaultValue = "" - }); - } private async Task OnInputFileChanged(InputFileChangeEventArgs args) { - Reset(); + ResetMapping(); ClearDragClass(); var files = args.GetMultipleFiles(); foreach (var file in files) @@ -267,14 +127,23 @@ private async Task OnInputFileChanged(InputFileChangeEventArgs args) CsvFile = files[0]; await ReadFile(files[0]); CreateCsvContent(); - MatchCsvHeadersWithExpectedHeaders(); + MatchSourceItemsWithExpectedHeaders(); } } - private void Reset() + + private void ResetMapping() { - MudCsvHeaders = new(); - ExpectedHeaders.ForEach(x => x.MatchedFieldCount = 0); + _sourceItems = new(); + CsvMapping.Clear(); + CsvContent = null; + FileContentByte = null; + _mapper?.ResetMapping(); + foreach (var header in ExpectedHeaders) + { + header.MatchedFieldCount = 0; + } } + private async Task ReadFile(IBrowserFile file) { long maxFileSize = 1024 * 1024 * 15; @@ -284,56 +153,43 @@ private async Task ReadFile(IBrowserFile file) await using var newFileStream = file.OpenReadStream(maxFileSize); int bytesRead; - double totalRead = 0; while ((bytesRead = await newFileStream.ReadAsync(buffer)) != 0) { - totalRead += bytesRead; await stream.WriteAsync(buffer, 0, bytesRead); } FileContentByte = stream.GetBuffer(); } + private void CreateCsvContent() { - using var reader = new StreamReader(new MemoryStream(FileContentByte ?? new byte[0]), Encoding.Default); + using var reader = new StreamReader(new MemoryStream(FileContentByte ?? Array.Empty()), Encoding.Default); var config = new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = Delimiter, IgnoreBlankLines = true, HasHeaderRecord = true }; - + using var csv = new CsvReader(reader, config); CsvContent = csv.GetRecords().Select(x => (IDictionary)x).ToList(); } /// - /// Matches the headers from the CSV content with the expected headers defined in the component. It first attempts an exact match, and if that fails, it tries to match using aliases. - /// If no match is found, it adds the CSV field as an unmapped header. + /// Matches CSV column names against the expected headers (exact match first, then aliases). + /// Unmatched columns are left in the source pool. /// - private void MatchCsvHeadersWithExpectedHeaders() + private void MatchSourceItemsWithExpectedHeaders() { + _sourceItems = new List(); var csvFields = CsvContent?.FirstOrDefault()?.Keys; - foreach (var csvField in csvFields?? new List()) + foreach (var csvField in csvFields ?? new List()) { - // You can add other matching try as FuzzySharp here - bool isMatched = TryExactMatch(csvField); - if (isMatched) continue; - - bool isAliasMatched = TryAliasMatch(csvField); - if (isAliasMatched) continue; - - MudCsvHeaders.Add(new MudCsvHeader(csvField)); + if (TryExactMatch(csvField)) continue; + if (TryAliasMatch(csvField)) continue; + _sourceItems.Add(new MudMapperItem(csvField, MudMapper.SourcePoolZoneIdentifier)); } - - IsValid(); - } - /// - /// Tries to match a CSV field with the expected headers using an exact match. - /// - /// The CSV field to match. - /// True if a match is found; otherwise, false. private bool TryExactMatch(string csvField) { foreach (var expectedField in ExpectedHeaders) @@ -341,18 +197,13 @@ private bool TryExactMatch(string csvField) if (string.Compare(expectedField.Name, csvField, StringComparison.CurrentCultureIgnoreCase) != 0) continue; if (expectedField.MatchedFieldCount != 0) continue; - MudCsvHeaders.Add(new MudCsvHeader(csvField, expectedField.Name)); + _sourceItems.Add(new MudMapperItem(csvField, expectedField.Name)); expectedField.MatchedFieldCount++; return true; } return false; } - /// - /// Tries to match a CSV field with the aliases of the expected headers. - /// - /// The CSV field to match. - /// True if a match is found; otherwise, false. private bool TryAliasMatch(string csvField) { foreach (var expectedField in ExpectedHeaders) @@ -361,7 +212,7 @@ private bool TryAliasMatch(string csvField) if (!expectedField.Aliases.Any(alias => string.Compare(alias, csvField, StringComparison.CurrentCultureIgnoreCase) == 0)) continue; if (expectedField.MatchedFieldCount != 0) continue; - MudCsvHeaders.Add(new MudCsvHeader(csvField, expectedField.Name)); + _sourceItems.Add(new MudMapperItem(csvField, expectedField.Name)); expectedField.MatchedFieldCount++; return true; } @@ -370,14 +221,19 @@ private bool TryAliasMatch(string csvField) private async Task OnImport() { - var config = new CsvHelper.Configuration.CsvConfiguration(CultureInfo.InvariantCulture) + var config = new CsvConfiguration(CultureInfo.InvariantCulture) { - PrepareHeaderForMatch = (header) => header.Header, + PrepareHeaderForMatch = header => header.Header, Delimiter = Delimiter }; - UpdateHeaderLineWithMatchedFields(); - if(!_includeUnmappedData) RemoveUnmappedData(); + + UpdateHeadersWithMappedFields(); + + bool includeUnmapped = _mapper?.IncludeUnmappedData ?? false; + if (!includeUnmapped) RemoveUnmappedData(); + AddDefaultValues(); + await using (var writer = new StringWriter()) await using (var csv = new CsvWriter(writer, config)) { @@ -387,46 +243,55 @@ private async Task OnImport() var str = writer.ToString(); FileContentByte = Encoding.UTF8.GetBytes(str); } + await OnImported.InvokeAsync(); - await Task.Delay(100); - _importedComplete = true; } - private void UpdateHeaderLineWithMatchedFields() + + private void UpdateHeadersWithMappedFields() { - foreach (var map in MudCsvHeaders) + var mappedItems = _sourceItems.Where(x => !MudMapper.IsSourcePoolItem(x)); + foreach (var map in mappedItems) { - if (map.MappedField == "File") continue; - var normalizedMappedField = Normalize(map.MappedField); + var normalizedTarget = Normalize(map.MappedZone); foreach (var row in CsvContent ?? new List>()) { var temp = row[map.Name]; row.Remove(map.Name); - row[normalizedMappedField] = temp; + row[normalizedTarget] = temp; } - CsvMapping.Add(map.MappedField, map.Name); + CsvMapping[map.MappedZone] = map.Name; } } + private void AddDefaultValues() { + AddDefaultValues(_mapper?.DefaultValues); + } + + internal void AddDefaultValues(IReadOnlyDictionary? defaultValues) + { + if (defaultValues == null) return; + foreach (var record in CsvContent ?? new List>()) { - foreach (var header in _defaultValueHeaders?.Where(header => header.Value.Confirmed) ?? new Dictionary()) + foreach (var header in defaultValues.Where(h => h.Value.Confirmed)) { - var normalizedDefaultHeader = Normalize(header.Key); + var normalizedKey = Normalize(header.Key); if (record.Keys.Contains(header.Key)) throw new Exception("Shouldn't happen"); - record[normalizedDefaultHeader] = header.Value.DefaultValue; + record[normalizedKey] = header.Value.DefaultValue; } } } + private void RemoveUnmappedData() { - var unMappedHeaders = MudCsvHeaders.Where(x => x.MappedField == "File").Select(x => x.Name); + var unmappedNames = _sourceItems.Where(x => MudMapper.IsSourcePoolItem(x)).Select(x => x.Name); foreach (var record in CsvContent ?? new List>()) { - foreach (var unMappedHeader in unMappedHeaders) + foreach (var name in unmappedNames) { - record.Remove(unMappedHeader); + record.Remove(name); } } } @@ -435,96 +300,20 @@ private string Normalize(string str) { return NormalizeHeaders ? str.Replace(" ", "").Replace("\"", "").ToLower() : str; } - /* handling board events */ - private void OnDrop(MudItemDropInfo mudCSVField) - { - string? oldMappedField = mudCSVField.Item?.MappedField; - if (mudCSVField.Item != null) - { - mudCSVField.Item.MappedField = mudCSVField.DropzoneIdentifier; - } - DecrementOldMatchedFieldCount(oldMappedField); - IncrementNewMatchedFieldCount(mudCSVField.DropzoneIdentifier); - IsValid(); - } - private void DecrementOldMatchedFieldCount(string? fieldName) - { - foreach (var expectedHeader in ExpectedHeaders.Where(expectedHeader => expectedHeader.Name == fieldName)) - { - expectedHeader.MatchedFieldCount--; - } - } - private void IncrementNewMatchedFieldCount(string? fieldName) - { - foreach (var expectedHeader in ExpectedHeaders) - { - if (expectedHeader.Name == fieldName) - { - expectedHeader.MatchedFieldCount++; - } - } - } - private void IsValid() - { - foreach (MudExpectedHeader requiredHeader in ExpectedHeaders.Where(i => i.Required)) - { - if (MudCsvHeaders.Any(i => i.MappedField == requiredHeader.Name)) continue; - if (_defaultValueHeaders?.Any(x => - x.Key == requiredHeader.Name && x.Value.Confirmed) == true) - { - continue; - } - _valid = false; - return; - } - _valid = true; - } + private void SetDragClass() { DragClass = $"{DefaultDragClass} mud-border-primary"; } + private void ClearDragClass() { DragClass = DefaultDragClass; } - private static bool ItemSelector(MudCsvHeader item, string? identifier) - { - return item.MappedField == identifier; - } - private void OpenAddSection() - { - _addSectionOpen = true; - } - private void SubmitDefaultValue(string? name) - { - if (_defaultValueHeaders == null) - { - return; - } - if (!string.IsNullOrWhiteSpace(_defaultValueHeaders[name ?? ""].DefaultValue)) - { - _defaultValueHeaders[name ?? ""].Confirmed = !_defaultValueHeaders[name ?? ""].Confirmed; - IsValid(); - } - } - private void OnSubmit(EditContext context) - { - if (string.IsNullOrWhiteSpace(_model.Name)) return; - ExpectedHeaders.Add(_model); - if (_model.AllowDefaultValue) - { - _defaultValueHeaders?.Add(_model.Name, new ConfirmedDefaultValue() - { - Confirmed = false, - DefaultValue = "" - }); - } - _model = new(); - _addSectionOpen = false; - } + private void ReloadPage() { - _navigationManager?.NavigateTo(_navigationManager.Uri, forceLoad:true); + _navigationManager?.NavigateTo(_navigationManager.Uri, forceLoad: true); } } } diff --git a/src/CodeBeam.MudBlazor.Extensions/CodeBeam.MudBlazor.Extensions.csproj b/src/CodeBeam.MudBlazor.Extensions/CodeBeam.MudBlazor.Extensions.csproj index 360039a8..3e1e46ae 100644 --- a/src/CodeBeam.MudBlazor.Extensions/CodeBeam.MudBlazor.Extensions.csproj +++ b/src/CodeBeam.MudBlazor.Extensions/CodeBeam.MudBlazor.Extensions.csproj @@ -6,7 +6,7 @@ enable MIT README.md - 9.1.0 + 9.2.0 CodeBeam.MudBlazor.Extensions CodeBeam.MudBlazor.Extensions CodeBeam @@ -58,7 +58,7 @@ <_Parameter1>CodeBeam.MudBlazor.Extensions.UnitTests - + True diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudBaseDatePickerX.cs b/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudBaseDatePickerX.cs index f6b9c008..30be27fd 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudBaseDatePickerX.cs +++ b/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudBaseDatePickerX.cs @@ -13,9 +13,13 @@ public abstract partial class MudBaseDatePickerX : MudPicker { internal readonly string _mudPickerCalendarContentElementId; private readonly ParameterState _formatState; + /// + /// The unique identifier used to associate picker elements. + /// protected readonly string _componentId = Identifier.Create(); internal DateTime? _picker_month; + internal bool _picker_month_set; /// /// Is set to true to scroll to the actual year after the next render @@ -107,7 +111,7 @@ protected MudBaseDatePickerX() /// The current month shown in the date picker. /// /// - /// Defaults to the current month.
+ /// Defaults to the current month. /// When bound via @bind-PickerMonth, controls the initial month displayed. This value is always the first day of a month. ///
[Parameter] @@ -119,6 +123,7 @@ public DateTime? PickerMonth if (value == _picker_month) return; _picker_month = value; + _picker_month_set = true; InvokeAsync(StateHasChanged); PickerMonthChanged.InvokeAsync(value); } @@ -133,7 +138,7 @@ public DateTime? PickerMonth /// The delay, in milliseconds, before closing the picker after a value is selected. ///
/// - /// Defaults to 100.
+ /// Defaults to 100. /// This delay helps the user see that a date has been selected before the popover disappears. ///
[Parameter] public int ClosingDelay { get; set; } = 100; @@ -150,7 +155,7 @@ public DateTime? PickerMonth /// The maximum number of months allowed in one row. ///
/// - /// Defaults to null.
+ /// Defaults to null. /// When null, the is used. ///
[Parameter] public int? MaxMonthColumns { get; set; } @@ -172,7 +177,7 @@ public DateTime? PickerMonth /// The format of the selected date in the title. ///
/// - /// Defaults to ddd, dd MMM.
+ /// Defaults to ddd, dd MMM. /// Supported date formats can be found here: . ///
[Parameter] public string TitleDateFormat { get; set; } = "ddd, dd MMM"; @@ -189,7 +194,7 @@ public DateTime? PickerMonth /// The function used to disable one or more dates. /// /// - /// Defaults to null.
+ /// Defaults to null. /// When set, a date will be disabled if the function returns true. ///
[Parameter] public Func IsDateDisabledFunc { get; set; } = _ => false; @@ -247,6 +252,17 @@ public DateTime? PickerMonth /// protected internal bool IsDateOnly => (Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T)) == typeof(DateOnly); + /// + /// Determines whether a non-nullable picker value is its default value. + /// + /// The value to evaluate. + /// when is non-nullable and equals its default value; otherwise, . + protected internal bool IsDefaultValue(T? value) + { + return Nullable.GetUnderlyingType(typeof(T)) is null && + EqualityComparer.Default.Equals(value, default); + } + /// /// Generic conversion method to convert the generic type T to DateTime. Supports DateTime and DateTimeOffset. /// @@ -306,6 +322,9 @@ public DateTime? PickerMonth throw new NotSupportedException($"Type {typeof(T)} not supported"); } + /// + /// Validates that is a supported date type. + /// protected void ValidateType() { var t = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); @@ -341,6 +360,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender) ScrollToYearAsync().CatchAndLog(); } + /// + /// Handles a change to the configured date format. + /// + /// The newly configured date format. + /// A task that represents the asynchronous operation. protected virtual Task DateFormatChangedAsync(string? newFormat) => Task.CompletedTask; private Task DateFormatChangedAsync(ParameterChangedEventArgs args) => DateFormatChangedAsync(args.Value); @@ -358,13 +382,24 @@ protected override async Task OnAfterRenderAsync(bool firstRender) || CurrentView == OpenTo.Month || CurrentView == OpenTo.Year; + /// protected override async Task OnPickerOpenedAsync() { await base.OnPickerOpenedAsync(); var dateTime = ToDateTime(_value); - if (dateTime.HasValue) + if (IsDefaultValue(_value)) + { + var culture = GetCulture(); + var calendar = culture.Calendar; + PickerMonth = new DateTime( + calendar.GetYear(DateTime.Today), + calendar.GetMonth(DateTime.Today), + 1, + calendar); + } + else if (dateTime.HasValue) { var culture = GetCulture(); var calendar = culture.Calendar; @@ -378,6 +413,11 @@ protected override async Task OnPickerOpenedAsync() CurrentView = OpenTo; } + /// + /// Gets the first day of the month at the specified offset from the displayed month. + /// + /// The zero-based offset from the displayed month. + /// The first day of the requested month. protected DateTime GetMonthStart(int month) { var culture = GetCulture(); @@ -387,6 +427,12 @@ protected DateTime GetMonthStart(int month) return calendar.AddMonths(new DateTime(baseDate.Year, baseDate.Month, 1), month); } + /// + /// Gets the seven days displayed for a calendar week. + /// + /// The zero-based offset from the displayed month. + /// The zero-based week index. + /// The dates in the requested week. protected IEnumerable GetWeek(int month, int index) { if (index is < 0 or > 5) @@ -403,6 +449,11 @@ protected IEnumerable GetWeek(int month, int index) yield return weekFirst.AddDays(i); } + /// + /// Determines whether a date is unavailable for selection. + /// + /// The date to evaluate. + /// when the date is disabled; otherwise, . protected virtual bool IsDayDisabled(DateTime date) { return date < MinDate || @@ -410,14 +461,35 @@ protected virtual bool IsDayDisabled(DateTime date) IsDateDisabledFunc(date); } + /// + /// Gets the CSS classes for a calendar day. + /// + /// The zero-based offset from the displayed month. + /// The day to format. + /// The CSS classes for the day. protected abstract string GetDayClasses(int month, DateTime day); + + /// + /// Handles selection of a calendar day. + /// + /// The selected date and time. + /// A task that represents the asynchronous operation. protected abstract Task OnDayClickedAsync(DateTime dateTime); + /// + /// Formats a date for the picker title. + /// + /// The date to format. + /// The formatted title, or an empty string when is . protected string FormatTitleDate(DateTime? date) { return date?.ToString(TitleDateFormat, GetCulture()) ?? ""; } + /// + /// Gets abbreviated day names ordered according to the configured first day of the week. + /// + /// The ordered abbreviated day names. protected IEnumerable GetAbbreviatedDayNames() { var culture = GetCulture(); @@ -428,6 +500,7 @@ protected IEnumerable GetAbbreviatedDayNames() return Enumerable.Range(0, 7).Select(i => names[(i + firstDay) % 7]); } + /// protected override IConverter GetDefaultConverter() { return new DefaultConverter @@ -437,6 +510,7 @@ protected IEnumerable GetAbbreviatedDayNames() }; } + /// protected override string? ConvertSet(T? value) { var dt = ToDateTime(value); @@ -447,6 +521,11 @@ protected IEnumerable GetAbbreviatedDayNames() return dt.Value.ToString(GetFormat(), GetCulture()); } + /// + /// Converts a picker value to its display text. + /// + /// The value to convert. + /// The formatted text, or when the value has no date representation. protected internal string? ConvertSetInternal(T? value) { return ConvertSet(value); @@ -472,14 +551,33 @@ protected override string GetFormat() return $"{CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern} HH:mm"; } + /// + /// Gets the initial month shown by the calendar. + /// + /// The first day of the initial month. protected abstract DateTime GetCalendarStartOfMonth(); + + /// + /// Gets the culture-specific calendar year for a date. + /// + /// The date whose calendar year is required. + /// The calendar year. protected abstract int GetCalendarYear(DateTime yearDate); + /// + /// Gets the first day of the calendar week. + /// + /// The configured first day of the week, or the culture default. protected DayOfWeek GetFirstDayOfWeek() { return FirstDayOfWeek ?? GetCulture().DateTimeFormat.FirstDayOfWeek; } + /// + /// Gets the last day of the month at the specified offset from the displayed month. + /// + /// The zero-based offset from the displayed month. + /// The last day of the requested month. protected DateTime GetMonthEnd(int month) { var culture = GetCulture(); @@ -503,4 +601,4 @@ public virtual Task ScrollToYearAsync(DateTime? date = null) //{ // return JsApiService.UpdateStyleProperty(_mudPickerCalendarContentElementId, "--selected-day", tempId); //} -} \ No newline at end of file +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor.cs b/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor.cs index d8049512..4950cedf 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor.cs +++ b/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor.cs @@ -16,6 +16,10 @@ public partial class MudDateTimePicker : MudBaseDatePickerX { [Inject] private IJSRuntime JsRuntime { get; set; } = null!; + + /// + /// Initializes a new instance of the class. + /// [DynamicDependency(nameof(OnStickClick))] [DynamicDependency(nameof(SelectTimeFromStick))] public MudDateTimePicker() @@ -34,12 +38,25 @@ public MudDateTimePicker() private record SetTime { + /// + /// Gets or sets the selected hour. + /// public int Hour { get; set; } + + /// + /// Gets or sets the selected minute. + /// public int Minute { get; set; } } + /// + /// Gets or sets whether the clock pointer is currently being dragged. + /// public bool PointerMoving { get; set; } + /// + /// Gets the element reference for the clock surface. + /// protected ElementReference ClockElementReference { get; private set; } private bool _amPm = false; @@ -47,14 +64,14 @@ private record SetTime protected override void OnInitialized() { base.OnInitialized(); - _workingValue = ToDateTime(Value); + _workingValue = BoundDateOrNull(); } /// protected override void OnParametersSet() { base.OnParametersSet(); - _workingValue = ToDateTime(Value); + _workingValue = BoundDateOrNull(); SyncTimeFromValue(); } @@ -85,8 +102,13 @@ private void SyncTimeFromValue() _timeSet.Minute = _workingValue.Value.Minute; } + /// + /// The active picker mode. + /// protected PickerMode _mode = PickerMode.Date; + private DateTime? BoundDateOrNull() => IsDefaultValue(_value) ? null : ToDateTime(Value); + /// /// The currently selected value. /// @@ -110,9 +132,9 @@ public T? Value /// Shows a 12-hour selection clock. /// /// - /// Defaults to false.
- /// When true, hours 1-12 are displayed with an AM or PM marker.
- /// When false, hours 0-23 are displayed.
+ /// Defaults to false. + /// When true, hours 1-12 are displayed with an AM or PM marker. + /// When false, hours 0-23 are displayed. ///
[Parameter] public bool AmPm @@ -176,6 +198,7 @@ private int RoundToStepInterval(int value) [Parameter] public string PmText { get; set; } = "PM"; + /// protected override async Task WriteTextAsync(string? text) { if (string.IsNullOrWhiteSpace(text)) @@ -230,6 +253,12 @@ private async Task OnPmClickedAsync() private DateTimeOffset _lastSetTime = DateTimeOffset.MinValue; private const int DebounceTimeoutMs = 100; + /// + /// Sets the selected date and optionally updates the input text and bound value. + /// + /// The date to set, or to clear the selection. + /// Whether to update the displayed text and reset conversion errors. + /// A task that represents the asynchronous operation. protected internal async Task SetDateAsync(DateTime? date, bool updateValue) { var current = ToDateTime(_value); @@ -326,6 +355,7 @@ private void SetTimePart(int hour, int minute) ); } + /// protected override string GetDayClasses(int month, DateTime day) { var b = new CssBuilder("mud-day"); @@ -335,7 +365,7 @@ protected override string GetDayClasses(int month, DateTime day) if (day < GetMonthStart(month) || day > GetMonthEnd(month)) return b.AddClass("mud-hidden").Build(); - var current = _workingValue ?? ToDateTime(Value); + var current = _workingValue ?? BoundDateOrNull(); if (current?.Date == day.Date) return b.AddClass("mud-selected") @@ -399,7 +429,7 @@ protected override async Task SubmitAsync() } /// - /// Clears the selected date and time, resetting the component to its initial state. If is true, the picker will also close after clearing the value. + /// Clears the selected date and time, resetting the component to its initial state. If AutoClose is true, the picker will also close after clearing the value. /// /// Indicates whether the picker should close after clearing the value. /// A task that represents the asynchronous operation. @@ -417,13 +447,35 @@ public override async Task ClearAsync(bool close = true) /// The formatted date string for the title of the picker. protected string GetTitleDateString() { - var date = _workingValue - ?? ToDateTime(Value) - ?? TimeProvider.GetLocalNow().Date; + var date = GetPickerHeaderDate(BoundDateOrNull() ?? _workingValue); return FormatTitleDate(date); } + /// + /// Gets the date used in the picker header. + /// + /// + /// A non-nullable date picker uses its default value as its cleared value. The header uses the current local date + /// as the picker reference date for that sentinel instead of presenting it as a selected value. + /// + private DateTime GetPickerHeaderDate(DateTime? date) + { + var boundDate = ToDateTime(_value); + + if (date is not null && (!IsDefaultValue(_value) || date != boundDate)) + return date.Value; + + // Prefer MinDate, then PickerMonth (only if explicitly set), then local current date. + if (MinDate.HasValue) + return MinDate.Value; + + if (PickerMonth.HasValue && _picker_month_set) + return PickerMonth.Value; + + return TimeProvider.GetLocalNow().Date; + } + /// /// Calculates the first day of the month for the current calendar context. /// @@ -433,7 +485,7 @@ protected string GetTitleDateString() /// or the current local date if neither is set. protected override DateTime GetCalendarStartOfMonth() { - var date = ToDateTime(Value) ?? HighlightedDate ?? TimeProvider.GetLocalNow().Date; + var date = BoundDateOrNull() ?? HighlightedDate ?? TimeProvider.GetLocalNow().Date; return date.StartOfMonth(GetCulture()); } @@ -448,24 +500,37 @@ protected override DateTime GetCalendarStartOfMonth() /// The calendar year as an integer, adjusted based on the current value and the specified date. protected override int GetCalendarYear(DateTime yearDate) { - var date = ToDateTime(Value) ?? TimeProvider.GetLocalNow().Date; + var date = BoundDateOrNull() ?? TimeProvider.GetLocalNow().Date; var diff = GetCulture().Calendar.GetYear(date) - GetCulture().Calendar.GetYear(yearDate); return GetCulture().Calendar.GetYear(date) - diff; } + /// + /// Gets the localized name of a displayed month. + /// + /// The zero-based offset from the displayed month. + /// The localized month name and year. protected string GetMonthName(int month) { var date = GetMonthStart(month); return date.ToString("MMMM yyyy", GetCulture()); } + /// + /// Moves the calendar to the previous month. + /// + /// A completed task. protected Task OnPreviousMonthClick() { PickerMonth = GetMonthStart(0).AddMonths(-1); return Task.CompletedTask; } + /// + /// Moves the calendar to the next month. + /// + /// A completed task. protected Task OnNextMonthClick() { PickerMonth = GetMonthStart(0).AddMonths(1); @@ -489,19 +554,32 @@ private void OnYearClick() } } + /// + /// Gets the earliest selectable year. + /// + /// The year from MinDate, or 1900 when no minimum is set. protected int GetMinYear() { return MinDate?.Year ?? 1900; } + /// + /// Gets the latest selectable year. + /// + /// The year from MaxDate, or 2100 when no maximum is set. protected int GetMaxYear() { return MaxDate?.Year ?? 2100; } + /// + /// Handles selection of a year in the calendar. + /// + /// The selected year. + /// A task that represents the asynchronous operation. protected Task OnYearClickedAsync(int year) { - var current = ToDateTime(Value) ?? TimeProvider.GetLocalNow().Date; + var current = BoundDateOrNull() ?? TimeProvider.GetLocalNow().Date; PickerMonth = new DateTime(year, current.Month, 1); _workingValue = new DateTime( @@ -517,41 +595,68 @@ protected Task OnYearClickedAsync(int year) return Task.CompletedTask; } + /// + /// Gets the typography style for a year. + /// + /// The year to evaluate. + /// The typography style for the year. protected Typo GetYearTypo(int year) { - var current = ToDateTime(Value); + var current = BoundDateOrNull(); return current?.Year == year ? Typo.h5 : Typo.body1; } + /// + /// Gets the CSS classes for a year. + /// + /// The year to evaluate. + /// The CSS classes for the year. protected string GetYearClasses(int year) { - var current = ToDateTime(Value); + var current = BoundDateOrNull(); return new CssBuilder("mud-picker-year-text") .AddClass("mud-selected", current?.Year == year) .Build(); } + /// + /// Moves the calendar to the previous year. + /// + /// A completed task. protected Task OnPreviousYearClick() { PickerMonth = (PickerMonth ?? DateTime.Today).AddYears(-1); return Task.CompletedTask; } + /// + /// Moves the calendar to the next year. + /// + /// A completed task. protected Task OnNextYearClick() { PickerMonth = (PickerMonth ?? DateTime.Today).AddYears(1); return Task.CompletedTask; } + /// + /// Gets all month numbers used by the month-selection view. + /// + /// The month numbers from 1 through 12. protected IEnumerable GetAllMonths() { return Enumerable.Range(1, 12); } + /// + /// Handles selection of a month in the calendar. + /// + /// The selected month number. + /// A task that represents the asynchronous operation. protected Task OnMonthSelectedAsync(int month) { - var current = _workingValue ?? ToDateTime(Value) ?? TimeProvider.GetLocalNow().Date; + var current = _workingValue ?? BoundDateOrNull() ?? TimeProvider.GetLocalNow().Date; PickerMonth = new DateTime(current.Year, month, 1); _workingValue = new DateTime( @@ -567,6 +672,11 @@ protected Task OnMonthSelectedAsync(int month) return Task.CompletedTask; } + /// + /// Determines whether every day in a month is outside the configured date range. + /// + /// The month number to evaluate. + /// when the month is disabled; otherwise, . protected bool IsMonthDisabled(int month) { if (!MinDate.HasValue && !MaxDate.HasValue) @@ -581,26 +691,47 @@ protected bool IsMonthDisabled(int month) || (MaxDate.HasValue && start > MaxDate.Value); } + /// + /// Gets the typography style for a month. + /// + /// The month number to evaluate. + /// The typography style for the month. protected Typo GetMonthTypo(int month) { - var current = ToDateTime(Value); + var current = BoundDateOrNull(); return current?.Month == month ? Typo.h6 : Typo.body2; } + /// + /// Gets the CSS classes for a month. + /// + /// The month number to evaluate. + /// The CSS classes for the month. protected string GetMonthClasses(int month) { - var current = ToDateTime(Value); + var current = BoundDateOrNull(); return new CssBuilder() .AddClass("mud-selected", current?.Month == month) .Build(); } + /// + /// Gets the localized abbreviated name of a month. + /// + /// The month number. + /// The abbreviated month name. protected string GetAbbreviatedMonthName(int month) { return GetCulture().DateTimeFormat.AbbreviatedMonthNames[month - 1]; } + /// + /// Gets the culture-specific week number for a displayed calendar week. + /// + /// The zero-based offset from the displayed month. + /// The zero-based week index. + /// The week number. protected int GetWeekNumber(int month, int week) { var firstDay = GetWeek(month, week).First(); @@ -611,16 +742,28 @@ protected int GetWeekNumber(int month, int week) GetFirstDayOfWeek()); } + /// + /// Gets the day-of-month text for a calendar date. + /// + /// The date to format. + /// The localized day-of-month text. protected string GetCalendarDayOfMonth(DateTime date) { return date.Day.ToString(GetCulture()); } + /// + /// Switches the picker to month selection. + /// protected void OnFormattedDateClick() { CurrentView = OpenTo.Month; } + /// + /// Switches the picker to month selection. + /// + /// The clicked month number. protected void OnMonthClicked(int month) { CurrentView = OpenTo.Month; @@ -750,6 +893,12 @@ private static string GetTransform(double angle, double radius, double offsetX, return $"transform: translate({x}px, {y}px);"; } + /// + /// Updates the working time from a clock-pointer movement. + /// + /// The hour or minute indicated by the clock pointer. + /// Whether the pointer is currently being dragged. + /// A task that represents the asynchronous operation. [JSInvokable] public async Task SelectTimeFromStick(int value, bool pointerMoving) { @@ -765,6 +914,11 @@ public async Task SelectTimeFromStick(int value, bool pointerMoving) StateHasChanged(); } + /// + /// Handles a click on the clock pointer and advances or submits the time selection. + /// + /// The selected hour or minute. + /// A task that represents the asynchronous operation. [JSInvokable] public async Task OnStickClick(int value) { @@ -792,6 +946,10 @@ public async Task OnStickClick(int value) StateHasChanged(); } + /// + /// Submits the working value and closes the picker when its configuration permits it. + /// + /// A task that represents the asynchronous operation. protected async Task SubmitAndCloseAsync() { if (PickerActions == null || AutoClose) @@ -871,11 +1029,13 @@ private void HandleModeChange(PickerMode mode) } } + /// + /// Gets the year displayed in the picker toolbar. + /// + /// The formatted year. protected string GetFormattedYearString() { - var date = _workingValue - ?? ToDateTime(Value) - ?? TimeProvider.GetLocalNow().Date; + var date = GetPickerHeaderDate(_workingValue ?? BoundDateOrNull()); return date.Year.ToString(); } @@ -893,7 +1053,7 @@ public override async Task ScrollToYearAsync(DateTime? date = null) var dateTime = date ?? _workingValue - ?? ToDateTime(Value) + ?? BoundDateOrNull() ?? TimeProvider.GetLocalNow().Date; var id = $"{_componentId}{calendar.GetYear(dateTime)}"; @@ -903,6 +1063,7 @@ public override async Task ScrollToYearAsync(DateTime? date = null) StateHasChanged(); } + /// protected override async Task OnOpenedAsync() { _mode = PickerMode.Date; diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/ListExtended/MudListExtended.razor.cs b/src/CodeBeam.MudBlazor.Extensions/Components/ListExtended/MudListExtended.razor.cs index c465e73e..1bd3d70f 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/ListExtended/MudListExtended.razor.cs +++ b/src/CodeBeam.MudBlazor.Extensions/Components/ListExtended/MudListExtended.razor.cs @@ -704,22 +704,16 @@ public IEnumerable>? SelectedItems /// /// /// - public override Task SetParametersAsync(ParameterView parameters) + public override async Task SetParametersAsync(ParameterView parameters) { if (_centralCommanderIsProcessing) - { - return Task.CompletedTask; - } - - if (MudSelectExtended != null || MudAutocomplete != null) - { - return Task.CompletedTask; - } - - base.SetParametersAsync(parameters).CatchAndLog(); + return; + // Hosted lists still need normal Blazor parameter flow. Previously every parameter set + // after the select/autocomplete cascade became available was discarded, freezing + // ItemCollection and all other parent-driven parameters while the popover was open. + await base.SetParametersAsync(parameters); _setParametersDone = true; - return Task.CompletedTask; } /// diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/ListExtended/MudListItemExtended.SelectionState.cs b/src/CodeBeam.MudBlazor.Extensions/Components/ListExtended/MudListItemExtended.SelectionState.cs new file mode 100644 index 00000000..0245d4f2 --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Components/ListExtended/MudListItemExtended.SelectionState.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; + +namespace MudExtensions +{ + public partial class MudListItemExtended + { + /// + /// Re-applies selection state whenever a list item receives parameters. + /// Virtualized lists can reuse an existing component instance for a different value, + /// so selection cannot rely only on registration/scroll timing. + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if (MudListExtended == null) + { + return; + } + + var comparer = MudListExtended.Comparer ?? EqualityComparer.Default; + + _selected = MudListExtended.MultiSelection + ? MudListExtended.SelectedValues?.Contains(Value, comparer) == true + : comparer.Equals(MudListExtended.SelectedValue, Value); + } + } +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/Mapper/MudMapper.razor b/src/CodeBeam.MudBlazor.Extensions/Components/Mapper/MudMapper.razor new file mode 100644 index 00000000..8c401a6e --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Components/Mapper/MudMapper.razor @@ -0,0 +1,125 @@ +@namespace MudExtensions +@inherits MudComponentBase + +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms + +
+ + + + + +
+ @if (SourceItems.Count > 0) + { + +

@LocalizedStrings.SourceItems

+
+ + +
+
+ } +

@LocalizedStrings.TargetHeaders

+
+ @if (TargetHeaders.Count == 0) + { + @LocalizedStrings.DefineHeaders + } + + @foreach (var item in TargetHeaders) + { + var required = item.Required; + var matched = item.MatchedFieldCount > 0; + var headerKey = item.Name ?? string.Empty; + var defaultValue = _defaultValueHeaders?.TryGetValue(headerKey, out var existing) == true ? existing : null; + bool confirmed = defaultValue?.Confirmed == true; + + + + + @item.Name + @if (required) + { + * + } + @if (!matched && item.CreatingDefaultValue) + { + + } + else if (!matched && item.AllowDefaultValue) + { + + } + + @if (!matched && !confirmed) + { + + @LocalizedStrings.DragHere + + } + @if (!matched && item.CreatingDefaultValue) + { + + @(defaultValue?.Confirmed == true ? "Edit" : "Confirm") + } + + + } + + + @if (AllowCreateTargetHeaders && _addSectionOpen) + { + + + + + Required + Allow Default Value + Add Header + + + } + else if (AllowCreateTargetHeaders) + { + Create Header + } + +
+
+
+ + @context.Name + +
+ + @if (ShowIncludeUnmappedData) + { + Include unmapped data + } + @if (!IsConfirmed) + { + @(ConfirmLabel ?? LocalizedStrings.Confirm) + } + else + { + @LocalizedStrings.Reset + } + +
diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/Mapper/MudMapper.razor.cs b/src/CodeBeam.MudBlazor.Extensions/Components/Mapper/MudMapper.razor.cs new file mode 100644 index 00000000..1b4a3b1a --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Components/Mapper/MudMapper.razor.cs @@ -0,0 +1,401 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using MudBlazor; +using MudBlazor.Utilities; +using MudExtensions.Utilities; + +namespace MudExtensions +{ + /// + /// Represents a confirmed default value for a target header when no source item is mapped. + /// + public class ConfirmedDefaultValue + { + /// + /// The default value to use. + /// + public string? DefaultValue { get; set; } + + /// + /// Whether the default value has been confirmed by the user. + /// + public bool Confirmed { get; set; } + } + + /// + /// Represents a target field that source items can be mapped onto. + /// + public class MudExpectedHeader + { + /// + /// CSS applied to the drop zone when this required header has no match. + /// + public readonly string? RequiredCss = "border-color: var(--mud-palette-error); color: var(--mud-palette-error);"; + + /// + /// The name of the target header. + /// + public string? Name { get; set; } = ""; + + /// + /// Aliases for the header. If any alias matches a source item name it is treated as a match. + /// + public IEnumerable? Aliases { get; set; } = null; + + /// + /// Whether this header must be mapped before the user can confirm. + /// + public bool Required { get; set; } + + /// + /// Whether the user may supply a default value instead of mapping a source item. + /// + public bool AllowDefaultValue { get; set; } + + /// + /// Internal UI state: whether the default-value entry form is expanded. + /// + public bool CreatingDefaultValue { get; set; } + + /// + /// Number of source items currently mapped to this header. + /// + public int MatchedFieldCount { get; set; } = 0; + + /// Initializes a new instance. + public MudExpectedHeader() { } + + /// Initializes a new instance with the given name. + public MudExpectedHeader(string? name) + { + Name = name; + Required = false; + } + + /// Initializes a new instance with the given name and required flag. + public MudExpectedHeader(string? name, bool required = false) + { + Name = name; + Required = required; + } + + /// Initializes a new instance with name, required and allowDefaultValue flags. + public MudExpectedHeader(string? name, bool required = false, bool allowDefaultValue = false) + { + Name = name; + Required = required; + AllowDefaultValue = allowDefaultValue; + } + + /// Initializes a new instance with all properties. + public MudExpectedHeader(string? name, bool required = false, bool allowDefaultValue = false, IEnumerable? aliases = null) + { + Name = name; + Required = required; + AllowDefaultValue = allowDefaultValue; + Aliases = aliases; + } + } + + /// + /// Represents a source item that can be dragged onto a target header zone. + /// + public class MudMapperItem + { + /// + /// The display name of the source item. + /// + public string Name { get; set; } = ""; + + /// + /// The identifier of the drop zone this item currently occupies. + /// Defaults to "Source" (the unassigned pool). + /// + public string MappedZone { get; set; } = "Source"; + + /// Initializes a new instance. + /// Display name. + /// Initial zone identifier; defaults to "Source". + public MudMapperItem(string? name, string? mappedZone = "Source") + { + Name = name ?? ""; + MappedZone = mappedZone ?? "Source"; + } + } + + /// + /// A standalone drag-and-drop field mapper component. + /// Allows users to map source items onto target headers without any file or CSV dependency. + /// + public partial class MudMapper : MudComponentBase + { + /// + /// The identifier for the drop zone that contains unassigned source items. + /// + public const string SourcePoolZoneIdentifier = "__mud_mapper_source_pool__"; + + /// + /// CSS class for the root element. + /// + protected string? Classname => + new CssBuilder("mud-mapper") + .AddClass(Class) + .Build(); + + /// + /// Localized display strings for the component. + /// + [Parameter] + public MudMapperLocalizedStrings LocalizedStrings { get; set; } = new(); + + /// + /// The list of target headers that source items can be mapped onto. + /// + [Parameter] + public List TargetHeaders { get; set; } = new(); + + /// + /// The source items available for mapping. Mutated in-place as the user drags items. + /// + [Parameter] + public List SourceItems { get; set; } = new(); + + /// + /// Whether the user may create new target headers at runtime. + /// + [Parameter] + public bool AllowCreateTargetHeaders { get; set; } + + /// + /// Whether to show the "Include unmapped data" toggle. + /// + [Parameter] + public bool ShowIncludeUnmappedData { get; set; } + + /// + /// Label for the confirm action button. Defaults to the value in . + /// + [Parameter] + public string? ConfirmLabel { get; set; } + + /// + /// Icon for the confirm action button. + /// + [Parameter] + public string ConfirmIcon { get; set; } = Icons.Material.Filled.Check; + + /// + /// Fires when the user clicks the confirm button and the mapping is valid. + /// + [Parameter] + public EventCallback OnConfirmed { get; set; } + + /// + /// Fires when the user clicks the reset button. + /// + [Parameter] + public EventCallback OnReset { get; set; } + + /// + /// Whether the user has already confirmed the mapping. + /// Controls whether the confirm button or the reset button is displayed. + /// + public bool IsConfirmed { get; private set; } + + /// + /// Whether the current mapping satisfies all required target headers. + /// + public bool IsValid => _valid; + + /// + /// Whether source items that are not mapped to any target should be included in the output. + /// Readable after the user confirms. + /// + public bool IncludeUnmappedData { get; private set; } + + /// + /// The confirmed default values keyed by target header name. + /// Readable after the user confirms. + /// + public IReadOnlyDictionary? DefaultValues => _defaultValueHeaders; + + [Inject] private NavigationManager? _navigationManager { get; set; } + + private bool _valid = false; + private readonly string _requiredDefaultValueMessage = "Default value is required if no header is mapped"; + private readonly string _expectedHeaderDropZoneWidth = "width: 180px;"; + private MudExpectedHeader _model { get; set; } = new(); + private bool _addSectionOpen; + private Dictionary? _defaultValueHeaders; + + /// + protected override void OnInitialized() + { + base.OnInitialized(); + EnsureDefaultValueHeaders(); + } + + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + EnsureDefaultValueHeaders(); + SyncMappedZonesToHeaders(); + } + + private static bool ItemSelector(MudMapperItem item, string? identifier) + { + return item.MappedZone == identifier; + } + + /// + /// Determines whether a given source item is currently in the unassigned source pool. + /// + /// The source item to check. + /// True if the item is in the unassigned source pool; otherwise, false. + public static bool IsSourcePoolItem(MudMapperItem item) + { + return string.Equals(item.MappedZone, SourcePoolZoneIdentifier, StringComparison.OrdinalIgnoreCase); + } + + private void EnsureDefaultValueHeaders() + { + var defaults = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in TargetHeaders.Where(x => x.AllowDefaultValue)) + { + var key = header.Name ?? string.Empty; + if (_defaultValueHeaders?.TryGetValue(key, out var existing) == true) + { + defaults[key] = new ConfirmedDefaultValue + { + Confirmed = existing.Confirmed, + DefaultValue = existing.DefaultValue + }; + } + else + { + defaults[key] = new ConfirmedDefaultValue { Confirmed = false, DefaultValue = "" }; + } + } + + _defaultValueHeaders = defaults; + } + + private void SyncMappedZonesToHeaders() + { + TargetHeaders.ForEach(h => h.MatchedFieldCount = 0); + + foreach (var item in SourceItems) + { + if (item == null) continue; + if (string.IsNullOrWhiteSpace(item.MappedZone)) + { + item.MappedZone = SourcePoolZoneIdentifier; + continue; + } + + if (string.Equals(item.MappedZone, SourcePoolZoneIdentifier, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var header = TargetHeaders.FirstOrDefault(h => string.Equals(h.Name, item.MappedZone, StringComparison.OrdinalIgnoreCase)); + if (header == null) + { + item.MappedZone = SourcePoolZoneIdentifier; + continue; + } + + header.MatchedFieldCount++; + } + + CheckValid(); + } + + private void OnDrop(MudItemDropInfo dropInfo) + { + if (dropInfo.Item == null || dropInfo.DropzoneIdentifier == null) + { + return; + } + + dropInfo.Item.MappedZone = dropInfo.DropzoneIdentifier; + SyncMappedZonesToHeaders(); + } + + private void CheckValid() + { + foreach (var requiredHeader in TargetHeaders.Where(h => h.Required)) + { + if (SourceItems.Any(i => i.MappedZone.Equals(requiredHeader.Name, StringComparison.OrdinalIgnoreCase))) continue; + if (_defaultValueHeaders?.Any(x => x.Key.Equals(requiredHeader.Name, StringComparison.OrdinalIgnoreCase) && x.Value.Confirmed) == true) continue; + _valid = false; + return; + } + _valid = true; + } + + private void OpenAddSection() => _addSectionOpen = true; + + private void SubmitDefaultValue(string? name) + { + if (_defaultValueHeaders == null) return; + var key = name ?? ""; + if (!string.IsNullOrWhiteSpace(_defaultValueHeaders[key].DefaultValue)) + { + _defaultValueHeaders[key].Confirmed = !_defaultValueHeaders[key].Confirmed; + CheckValid(); + } + } + + private void OnSubmit(EditContext context) + { + if (string.IsNullOrWhiteSpace(_model.Name)) return; + TargetHeaders.Add(_model); + if (_model.AllowDefaultValue) + { + _defaultValueHeaders?.Add(_model.Name, new ConfirmedDefaultValue { Confirmed = false, DefaultValue = "" }); + } + _model = new(); + _addSectionOpen = false; + } + + private async Task HandleConfirm() + { + IsConfirmed = true; + await OnConfirmed.InvokeAsync(); + } + + private async Task HandleReset() + { + IsConfirmed = false; + await OnReset.InvokeAsync(); + } + + private void OnIncludeUnmappedDataChanged(bool value) + { + IncludeUnmappedData = value; + } + + /// + /// Resets mapping state so the component can be reused for a new set of source items. + /// + public void ResetMapping() + { + IsConfirmed = false; + IncludeUnmappedData = false; + foreach (var item in SourceItems) + { + item.MappedZone = SourcePoolZoneIdentifier; + } + _defaultValueHeaders = TargetHeaders + .Where(x => x.AllowDefaultValue) + .ToDictionary( + key => key.Name ?? string.Empty, + _ => new ConfirmedDefaultValue { Confirmed = false, DefaultValue = "" }, + StringComparer.OrdinalIgnoreCase); + SyncMappedZonesToHeaders(); + _valid = false; + } + } +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/IMudSelectExtended.cs b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/IMudSelectExtended.cs index b20653a9..44b7f59b 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/IMudSelectExtended.cs +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/IMudSelectExtended.cs @@ -6,8 +6,4 @@ internal interface IMudSelectExtended void CheckGenericTypeMatch(object select_item); bool MultiSelection { get; set; } } - - internal interface IMudShadowSelectExtended - { - } } diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.PresentationState.cs b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.PresentationState.cs new file mode 100644 index 00000000..25feeab5 --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.PresentationState.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Components; + +namespace MudExtensions +{ + public partial class MudSelectExtended + { + private bool ValuesEqual(T? left, T? right) + => (_comparer ?? EqualityComparer.Default).Equals(left, right); + + internal bool PresentationValuesEqual(T? left, T? right) + => ValuesEqual(left, right); + + private bool TryGetCollectionValue(T? value, out T? collectionValue) + { + if (ItemCollection != null) + { + foreach (var item in ItemCollection) + { + if (ValuesEqual(item, value)) + { + collectionValue = item; + return true; + } + } + } + + collectionValue = default; + return false; + } + + private MudSelectItemExtended? FindRegisteredItem(T? value) + => Items?.FirstOrDefault(item => ValuesEqual(item.Value, value)); + + internal bool IsSelectedPresentationValue(T? value) + => MultiSelection + ? SelectedValues?.Contains(value, _comparer) == true + : ValuesEqual(ReadValue, value); + + private bool _declarativePresentationDirty; + + internal void MarkDeclarativePresentationDirty() + => _declarativePresentationDirty = true; + + /// + /// Resolves the selected option for ItemContent presentation without requiring that + /// option to have a currently rendered list-item component. + /// + private MudListItemExtended? GetSelectedPresentationItem() + { + if (ItemCollection != null) + { + if (!TryGetCollectionValue(ReadValue, out var collectionValue)) + return null; + + return CreatePresentationItem( + collectionValue, + ToStringFunc?.Invoke(collectionValue) ?? collectionValue?.ToString(), + childContent: null, + disabled: ItemDisabledFunc?.Invoke(collectionValue) == true); + } + + var registeredItem = FindRegisteredItem(ReadValue); + if (registeredItem != null) + { + return CreatePresentationItem( + registeredItem.Value, + registeredItem.Text, + registeredItem.ChildContent, + ItemDisabledFunc?.Invoke(registeredItem.Value) ?? registeredItem.Disabled, + registeredItem.IsFunctional, + registeredItem.Href, + registeredItem.Class, + registeredItem.Style); + } + + return SelectedListItem != null && ValuesEqual(SelectedListItem.Value, ReadValue) + ? SelectedListItem + : null; + } + +#pragma warning disable BL0005 // Parameters are populated on an unrendered context object for ItemTemplate. + private static MudListItemExtended CreatePresentationItem( + T? value, + string? text, + RenderFragment? childContent, + bool disabled, + bool isFunctional = false, + string? href = null, + string? @class = null, + string? style = null) + { + var item = new MudListItemExtended + { + Value = value, + Text = text, + ChildContent = childContent, + Disabled = disabled, + IsFunctional = isFunctional, + Href = href, + Class = @class, + Style = style + }; + + item.SetSelected(true, forceRender: false); + return item; + } +#pragma warning restore BL0005 + } +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.SelectionText.cs b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.SelectionText.cs new file mode 100644 index 00000000..2f2c5d61 --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.SelectionText.cs @@ -0,0 +1,20 @@ +namespace MudExtensions +{ + public partial class MudSelectExtended + { + /// + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + // Reconcile collection-backed presentation after the complete parameter set has + // been applied. ItemCollection and the selected values are authoritative, so display + // text is derived from their current state rather than retained item components. + // + // This also covers mutable selected objects whose display representation changes + // without replacing the collection or selected-value reference. + if (ItemCollection is not null) + await UpdateTextPropertyAsync(false); + } + } +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor index 18d6f7a5..70c476a0 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor @@ -1,4 +1,4 @@ -@namespace MudExtensions +@namespace MudExtensions @typeparam T @inherits MudBaseInputExtended @@ -74,14 +74,14 @@ @if (ItemCollection != null) { - foreach (var item in ItemCollection.Where(x => SelectedValues?.Contains(x) == true)) + foreach (var item in ItemCollection.Where(x => SelectedValues?.Contains(x, _comparer) == true)) { } } else { - foreach (var item in Items?.Where(x => SelectedValues?.Contains(x.Value) == true) ?? new List>()) + foreach (var item in Items?.Where(x => SelectedValues?.Contains(x.Value, _comparer) == true) ?? new List>()) { } @@ -90,25 +90,19 @@ } else if (ValuePresenter == ValuePresenter.ItemContent) { - if (SelectedListItem == null) + var selectedPresentationItem = GetSelectedPresentationItem(); + + if (selectedPresentationItem == null) { @Placeholder - } else if (ItemTemplate != null) { - if (SelectedListItem != null && SelectedListItem.Value != null) - { - @ItemTemplate(SelectedListItem) - } - else if (!string.IsNullOrEmpty(Placeholder)) - { - @Placeholder - } + @ItemTemplate(selectedPresentationItem) } - else if (SelectedListItem.ChildContent != null) + else if (selectedPresentationItem.ChildContent != null) { - @SelectedListItem.ChildContent + @selectedPresentationItem.ChildContent } else { @@ -159,26 +153,15 @@ - @*To Be Discussed as Second ShadowItems: Needed to render directly into select to has initial values*@ - -
- - - @ChildContent - + @* Declarative child items are registered once as metadata so selected text/content is + available while the popover is closed. Collection-backed selects are value-driven and + do not need a hidden item tree. *@ + @if (ItemCollection == null && ChildContent != null) + { + + @ChildContent -
- - - - - @ChildContent - + } diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor.cs b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor.cs index 838e5710..baaefa11 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor.cs +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectExtended.razor.cs @@ -11,7 +11,7 @@ namespace MudExtensions /// Select component with advanced features. ///
/// - public partial class MudSelectExtended : MudBaseInputExtended, IMudSelectExtended, IMudShadowSelectExtended + public partial class MudSelectExtended : MudBaseInputExtended, IMudSelectExtended { #region Constructor, Injected Services, Parameters, Fields @@ -46,18 +46,12 @@ public MudSelectExtended() /// /// /// - protected Dictionary> _valueLookup = new(); - /// - /// - /// - protected Dictionary> _shadowLookup = new(); private MudInputExtended _elementReference = new(); internal bool _isOpen; /// /// /// protected internal string? _currentIcon { get; set; } - internal event Action>? SelectionChangedFromOutside; /// /// @@ -578,7 +572,6 @@ public IEnumerable? SelectedValues } _selectedValuesSetterStarted = true; _selectedValues = new HashSet(set, _comparer); - SelectionChangedFromOutside?.Invoke(new HashSet(_selectedValues, _comparer)); if (!MultiSelection) { SetValueAndUpdateTextAsync(_selectedValues.FirstOrDefault()).CatchAndLog(); @@ -694,34 +687,43 @@ protected override Task UpdateValuePropertyAsync(bool updateText) /// protected override Task UpdateTextPropertyAsync(bool updateValue) { - List textList = new List(); - if (Items != null && Items.Any()) + List textList = new(); + + if (ItemCollection != null) { - if (ItemCollection != null) + foreach (var selectedValue in SelectedValues ?? []) { - foreach (var val in SelectedValues ?? new List()) + if (TryGetCollectionValue(selectedValue, out var collectionValue)) { - var collectionValue = ItemCollection.FirstOrDefault(x => x != null && (Comparer != null ? Comparer.Equals(x, val) : x.Equals(val))); - if (collectionValue != null) - { - textList.Add(ConvertSet(collectionValue)); - } + textList.Add(ConvertSet(collectionValue)); + } + else if (!Strict) + { + textList.Add(ConvertSet(selectedValue)); } } - else + } + else if (Items is { Count: > 0 }) + { + foreach (var selectedValue in SelectedValues ?? []) { - foreach (var val in SelectedValues ?? new List()) + // Preserve the existing declarative-item behavior: when a non-strict external + // selection is represented by a different object instance, present that selected + // value through the converter even if the custom comparer maps it to an item. + if (!Strict && !Items.Select(item => item.Value).Contains(selectedValue)) { - if (!Strict && !Items.Select(x => x.Value).Contains(val)) - { - textList.Add(ToStringFunc != null ? ToStringFunc(val) : ConvertSet(val)); - continue; - } - var item = Items.FirstOrDefault(x => x != null && (x.Value == null ? val == null : Comparer != null ? Comparer.Equals(x.Value, val) : x.Value.Equals(val))); - if (item != null) - { - textList.Add(!string.IsNullOrEmpty(item.Text) ? item.Text : ConvertSet(item.Value)); - } + textList.Add(ConvertSet(selectedValue)); + continue; + } + + var item = FindRegisteredItem(selectedValue); + if (item != null) + { + textList.Add(!string.IsNullOrEmpty(item.Text) ? item.Text : ConvertSet(item.Value)); + } + else if (!Strict) + { + textList.Add(ConvertSet(selectedValue)); } } } @@ -736,20 +738,23 @@ protected override Task UpdateTextPropertyAsync(bool updateValue) selectedConvertedValues: SelectedValues?.ToList(), multiSelectionTextFunc: MultiSelectionTextFunc, updateValue: updateValue); } - else - { - return SetTextAndUpdateValueAsync(string.Join(Delimiter, textList), updateValue: updateValue); - } + + return SetTextAndUpdateValueAsync(string.Join(Delimiter, textList), updateValue: updateValue); } - else + + if (ItemCollection != null) { - var item = Items?.FirstOrDefault(x => ReadValue == null ? x.Value == null : Comparer != null ? Comparer.Equals(ReadValue, x.Value) : ReadValue.Equals(x.Value)); - if (item == null) - { - return SetTextAndUpdateValueAsync(ConvertSet(ReadValue), false); - } - return SetTextAndUpdateValueAsync((!string.IsNullOrEmpty(item.Text) ? item.Text : ConvertSet(item.Value)), updateValue: updateValue); + return TryGetCollectionValue(ReadValue, out var collectionValue) + ? SetTextAndUpdateValueAsync(ConvertSet(collectionValue), updateValue: updateValue) + : SetTextAndUpdateValueAsync(ConvertSet(ReadValue), false); } + + var registeredItem = FindRegisteredItem(ReadValue); + return registeredItem == null + ? SetTextAndUpdateValueAsync(ConvertSet(ReadValue), false) + : SetTextAndUpdateValueAsync( + !string.IsNullOrEmpty(registeredItem.Text) ? registeredItem.Text : ConvertSet(registeredItem.Value), + updateValue: updateValue); } private string? GetSelectTextPresenter() @@ -824,10 +829,17 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await KeyInterceptorService.SubscribeAsync(_elementId, options, keyDown: HandleKeyDownAsync, keyUp: HandleKeyUpAsync); await UpdateTextPropertyAsync(false); + _declarativePresentationDirty = false; _list?.ForceUpdateItems(); - SelectedListItem = Items.FirstOrDefault(x => x.Value != null && ReadValue != null && x.Value.Equals(ReadValue))?.ListItem; StateHasChanged(); } + else if (_declarativePresentationDirty) + { + _declarativePresentationDirty = false; + await UpdateTextPropertyAsync(false); + StateHasChanged(); + } + //Console.WriteLine("Select rendered"); await base.OnAfterRenderAsync(firstRender); } @@ -1072,12 +1084,37 @@ public async Task CloseMenu() /// public async Task SelectOption(int index) { + if (ItemCollection != null) + { + if (AddNullItem) + { + if (index == 0) + { + await SelectOption(default(T)); + return; + } + + index--; + } + + if (index < 0 || index >= ItemCollection.Count) + { + if (!MultiSelection) + await CloseMenu(); + return; + } + + await SelectOption(ItemCollection.ElementAt(index)); + return; + } + if (index < 0 || index >= _items.Count) { if (!MultiSelection) await CloseMenu(); return; } + await SelectOption(_items[index].Value); } @@ -1136,24 +1173,18 @@ protected internal bool Add(MudSelectItemExtended? item) { if (item == null) return false; - bool? result = null; - if (!_items.Select(x => x.Value).Contains(item.Value)) + + var isSelected = IsSelectedPresentationValue(item.Value); + + if (!_items.Contains(item) && FindRegisteredItem(item.Value) == null) { _items.Add(item); - if (item.Value != null) - { - _valueLookup[item.Value] = item; - if (item.Value.Equals(ReadValue) && !MultiSelection) - result = true; - } - } - //UpdateSelectAllChecked(); - if (!result.HasValue) - { - result = item.Value?.Equals(ReadValue); + if (isSelected) + MarkDeclarativePresentationDirty(); } - return result == true; + + return ValuesEqual(item.Value, ReadValue); } /// @@ -1163,34 +1194,12 @@ protected internal bool Add(MudSelectItemExtended? item) protected internal void Remove(MudSelectItemExtended? item) { if (item == null) - { return; - } - _items.Remove(item); - if (item.Value != null) - _valueLookup.Remove(item.Value); - } - /// - /// - /// - /// - public void RegisterShadowItem(MudSelectItemExtended? item) - { - if (item == null || item.Value == null) - return; - _shadowLookup[item.Value] = item; - } + var wasSelected = IsSelectedPresentationValue(item.Value); - /// - /// - /// - /// - public void UnregisterShadowItem(MudSelectItemExtended? item) - { - if (item == null || item.Value == null) - return; - _shadowLookup.Remove(item.Value); + if (_items.Remove(item) && wasSelected) + MarkDeclarativePresentationDirty(); } #endregion @@ -1248,15 +1257,11 @@ protected bool IsValueInList { if (Value == null) return false; - //return _shadowLookup.TryGetValue(Value, out var _); - foreach (var value in Items?.Select(x => x.Value) ?? new List()) - { - if (Comparer != null ? Comparer.Equals(value, Value) : value?.Equals(Value) == true) //(Converter.Set(item.Value) == Converter.Set(Value)) - { - return true; - } - } - return false; + + if (ItemCollection != null) + return ItemCollection.Any(item => ValuesEqual(item, Value)); + + return Items?.Any(item => ValuesEqual(item.Value, Value)) == true; } } diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemExtended.razor.cs b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemExtended.razor.cs index f42fc13c..c91ba142 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemExtended.razor.cs +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemExtended.razor.cs @@ -15,40 +15,33 @@ public partial class MudSelectItemExtended : MudComponentBase, IDisposable .Build(); private IMudSelectExtended? _parent; - internal MudSelectExtended? MudSelectExtended => (MudSelectExtended?)IMudSelectExtended; + private MudSelectExtended? _registeredSelect; + private bool _hasRegisteredPresentationMetadata; + private T? _registeredValue; + private string? _registeredDisplayString; + private bool _registeredDisabled; + private bool _registeredIsFunctional; + private string? _registeredHref; + private string? _registeredClass; + private string? _registeredStyle; + + internal MudSelectExtended? MudSelectExtended => (MudSelectExtended?)_parent; + /// /// /// public MudListItemExtended ListItem { get; set; } = new(); internal string ItemId { get; } = Identifier.Create("selectItem_"); - private IMudShadowSelectExtended? _shadowParent; /// - /// The parent select component + /// The parent select component. Registration is reconciled in OnParametersSet so all + /// cascading parameters (including HideContent) have been applied before side effects run. /// [CascadingParameter] internal IMudSelectExtended? IMudSelectExtended { get => _parent; - set - { - _parent = value; - if (_parent == null) - return; - _parent.CheckGenericTypeMatch(this); - if (MudSelectExtended == null) - return; - bool isSelected = MudSelectExtended.Add(this); - if (_parent.MultiSelection) - { - MudSelectExtended.SelectionChangedFromOutside += OnUpdateSelectionStateFromOutside; - InvokeAsync(() => OnUpdateSelectionStateFromOutside(MudSelectExtended.SelectedValues)); - } - else - { - IsSelected = isSelected; - } - } + set => _parent = value; } /// @@ -72,16 +65,6 @@ internal IMudSelectExtended? IMudSelectExtended [CascadingParameter(Name = "HideContent")] internal bool HideContent { get; set; } - private void OnUpdateSelectionStateFromOutside(IEnumerable? selection) - { - if (selection == null) - return; - var old_is_selected = IsSelected; - IsSelected = selection.Contains(Value); - if (old_is_selected != IsSelected) - InvokeAsync(StateHasChanged); - } - /// /// A user-defined option that can be selected /// @@ -131,18 +114,6 @@ protected bool MultiSelection public bool Disabled { get; set; } - private bool _isSelected; - internal bool IsSelected - { - get => _isSelected; - set - { - if (_isSelected == value) - return; - _isSelected = value; - } - } - /// /// /// @@ -191,6 +162,76 @@ protected bool GetDisabledStatus() return Disabled; } + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + _parent?.CheckGenericTypeMatch(this); + var select = MudSelectExtended; + + // Declarative ChildContent uses a metadata-only pass (HideContent=true). Collection- + // backed items are always transient view components; ItemCollection and selected values + // are authoritative regardless of whether the visible list itself is virtualized. + var shouldRegister = select != null && select.ItemCollection == null && HideContent; + var previousValue = _registeredValue; + var presentationMetadataChanged = HasRegisteredPresentationMetadataChanged(select); + + if (!shouldRegister || !ReferenceEquals(_registeredSelect, select)) + { + _registeredSelect?.Remove(this); + _registeredSelect = null; + _hasRegisteredPresentationMetadata = false; + } + + if (shouldRegister && _registeredSelect == null) + { + select!.Add(this); + _registeredSelect = select; + CaptureRegisteredPresentationMetadata(); + } + else if (shouldRegister && presentationMetadataChanged) + { + CaptureRegisteredPresentationMetadata(); + + if (select!.IsSelectedPresentationValue(previousValue) || + select.IsSelectedPresentationValue(Value)) + { + select.MarkDeclarativePresentationDirty(); + } + } + } + + private bool HasRegisteredPresentationMetadataChanged(MudSelectExtended? select) + { + if (!_hasRegisteredPresentationMetadata) + return false; + + var valueChanged = select == null + ? !EqualityComparer.Default.Equals(_registeredValue, Value) + : !select.PresentationValuesEqual(_registeredValue, Value); + + return valueChanged + || _registeredDisplayString != DisplayString + || _registeredDisabled != Disabled + || _registeredIsFunctional != IsFunctional + || _registeredHref != Href + || _registeredClass != Class + || _registeredStyle != Style; + } + + private void CaptureRegisteredPresentationMetadata() + { + _registeredValue = Value; + _registeredDisplayString = DisplayString; + _registeredDisabled = Disabled; + _registeredIsFunctional = IsFunctional; + _registeredHref = Href; + _registeredClass = Class; + _registeredStyle = Style; + _hasRegisteredPresentationMetadata = true; + } + /// /// /// @@ -198,8 +239,8 @@ public void Dispose() { try { - MudSelectExtended?.Remove(this); - ((MudSelectExtended?)_shadowParent)?.UnregisterShadowItem(this); + _registeredSelect?.Remove(this); + _registeredSelect = null; } catch (Exception) { } } diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemGroupExtended.razor b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemGroupExtended.razor index 5b91949b..fc37e94d 100644 --- a/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemGroupExtended.razor +++ b/src/CodeBeam.MudBlazor.Extensions/Components/SelectExtended/MudSelectItemGroupExtended.razor @@ -2,19 +2,21 @@ @typeparam T @inherits MudComponentBase -@if (!HideContent) +@if (HideContent) { - if (Nested) - { - - - @ChildContent - - - } - else - { - @Text - @ChildContent - } -} \ No newline at end of file + @* Preserve declarative child-item metadata without materializing list/group UI. *@ + @ChildContent +} +else if (Nested) +{ + + + @ChildContent + + +} +else +{ + @Text + @ChildContent +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Utilities/MudMapperLocalizedStrings.cs b/src/CodeBeam.MudBlazor.Extensions/Utilities/MudMapperLocalizedStrings.cs new file mode 100644 index 00000000..6c225b13 --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Utilities/MudMapperLocalizedStrings.cs @@ -0,0 +1,16 @@ +namespace MudExtensions.Utilities +{ + /// + /// Localized strings for MudMapper. + /// + public class MudMapperLocalizedStrings + { +#pragma warning disable CS1591 + public string? SourceItems { get; set; } = "Source Items"; + public string? TargetHeaders { get; set; } = "Target Headers"; + public string? DragHere { get; set; } = "Drag Here"; + public string? DefineHeaders { get; set; } = "Please define your expected target headers"; + public string? Confirm { get; set; } = "Confirm"; + public string? Reset { get; set; } = "Reset"; + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/MutableSelectItem.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/MutableSelectItem.cs new file mode 100644 index 00000000..35fc8de7 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/MutableSelectItem.cs @@ -0,0 +1,14 @@ +namespace MudExtensions.UnitTests.TestComponents; + +public sealed class MutableSelectItem +{ + public MutableSelectItem(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; } + + public string Name { get; set; } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectDeclarativeSelectedItemMutationTest.razor b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectDeclarativeSelectedItemMutationTest.razor new file mode 100644 index 00000000..0cbac5df --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectDeclarativeSelectedItemMutationTest.razor @@ -0,0 +1,20 @@ +@namespace MudExtensions.UnitTests.TestComponents + + + + + + + +@code { + private const string ItemValue = "one"; + + public string Selected { get; } = ItemValue; + public string ItemText { get; private set; } = "One"; + + public void RenameSelectedItem(string text) + { + ItemText = text; + StateHasChanged(); + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectItemCollectionRefreshTest.razor b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectItemCollectionRefreshTest.razor new file mode 100644 index 00000000..76e26dfd --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectItemCollectionRefreshTest.razor @@ -0,0 +1,18 @@ +@namespace MudExtensions.UnitTests.TestComponents + + + + + +@code { + public List Items { get; } = Enumerable.Range(1, 5) + .Select(value => $"Item {value}") + .ToList(); + + public void AddMore() + { + var next = Items.Count + 1; + Items.AddRange(Enumerable.Range(next, 5).Select(value => $"Item {value}")); + StateHasChanged(); + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectSingleItemCollectionMutationTest.razor b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectSingleItemCollectionMutationTest.razor new file mode 100644 index 00000000..19cc2d96 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectSingleItemCollectionMutationTest.razor @@ -0,0 +1,32 @@ +@namespace MudExtensions.UnitTests.TestComponents + + + + + +@code { + public List Items { get; } = + [ + new(1, "One"), + new(2, "Two"), + new(3, "Three") + ]; + + public MutableSelectItem Selected { get; } + + public SelectSingleItemCollectionMutationTest() + { + Selected = Items[1]!; + } + + private static string? Format(MutableSelectItem? item) => item?.Name; + + public void RenameSelected(string name) + { + Selected.Name = name; + StateHasChanged(); + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectTextValueTest.razor b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectTextValueTest.razor index 4bd03a54..865de861 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectTextValueTest.razor +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests.Viewer/TestComponents/SelectExtended/SelectTextValueTest.razor @@ -1,4 +1,6 @@ @namespace MudExtensions.UnitTests.TestComponents + + diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/CsvMapperTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/CsvMapperTests.cs index bc6ae297..7c1e03f7 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/CsvMapperTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/CsvMapperTests.cs @@ -60,7 +60,7 @@ public void CsvHeaders_Should_Match_ExpectedHeaders_Exactly() .GetField("CsvContent", BindingFlags.NonPublic | BindingFlags.Instance)! .SetValue(cut.Instance, csvContent); - InvokePrivate(cut.Instance, "MatchCsvHeadersWithExpectedHeaders"); + InvokePrivate(cut.Instance, "MatchSourceItemsWithExpectedHeaders"); expectedHeaders[0].MatchedFieldCount.Should().Be(1); expectedHeaders[1].MatchedFieldCount.Should().Be(1); @@ -93,7 +93,7 @@ public void CsvHeaders_Should_Match_ExpectedHeaders_Aliases() .GetField("CsvContent", BindingFlags.NonPublic | BindingFlags.Instance)! .SetValue(cut.Instance, csvContent); - InvokePrivate(cut.Instance, "MatchCsvHeadersWithExpectedHeaders"); + InvokePrivate(cut.Instance, "MatchSourceItemsWithExpectedHeaders"); expectedHeaders[0].MatchedFieldCount.Should().Be(1); expectedHeaders[1].MatchedFieldCount.Should().Be(1); @@ -141,9 +141,9 @@ public void AddDefaultValues_Should_Add_Confirmed_Defaults() } }; - SetPrivateMember(cut.Instance, "_defaultValueHeaders", defaults); - - InvokePrivate(cut.Instance, "AddDefaultValues"); + InvokePrivateWithArgs(cut.Instance, "AddDefaultValues", + new[] { typeof(IReadOnlyDictionary) }, + new object[] { (IReadOnlyDictionary)defaults }); csvContent[0].ContainsKey("Age").Should().BeTrue(); csvContent[0]["Age"].Should().Be("18"); @@ -155,13 +155,13 @@ public void RemoveUnmappedData_Should_Remove_Unmapped_Fields() { var cut = Context.Render(); - var headers = new List + var headers = new List { - new("A", "File"), + new("A", MudMapper.SourcePoolZoneIdentifier), new("B", "Mapped") }; - SetPrivateMember(cut.Instance, "MudCsvHeaders", headers); + SetPrivateMember(cut.Instance, "_sourceItems", headers); var csvContent = new List> { @@ -180,8 +180,90 @@ public void RemoveUnmappedData_Should_Remove_Unmapped_Fields() csvContent[0].ContainsKey("B").Should().BeTrue(); } + [Test] + public void RemoveUnmappedData_Should_Not_Treat_Target_Header_Name_Source_As_Unmapped() + { + var cut = Context.Render(); + + var headers = new List + { + new("A", "Source"), + new("B", MudMapper.SourcePoolZoneIdentifier) + }; + + SetPrivateMember(cut.Instance, "_sourceItems", headers); + + var csvContent = new List> + { + new Dictionary + { + ["A"] = 1, + ["B"] = 2 + } + }; + + SetPrivateMember(cut.Instance, "CsvContent", csvContent); + + InvokePrivate(cut.Instance, "RemoveUnmappedData"); + + csvContent[0].ContainsKey("A").Should().BeTrue(); + csvContent[0].ContainsKey("B").Should().BeFalse(); + } + + [Test] + public void ResetMapping_Should_Clear_Imported_State_And_Reset_Header_Counts() + { + var cut = Context.Render(); + var expectedHeaders = new List { new("Age") }; + expectedHeaders[0].MatchedFieldCount = 2; + + cut.Instance.ExpectedHeaders = expectedHeaders; + SetPrivateMember(cut.Instance, "_sourceItems", new List { new("Name", "Name") }); + SetPrivateMember(cut.Instance, "CsvContent", new List> + { + new Dictionary { ["Name"] = "Test" } + }); + cut.Instance.FileContentByte = new byte[] { 1, 2, 3 }; + cut.Instance.CsvMapping["Age"] = "Name"; + + InvokePrivate(cut.Instance, "ResetMapping"); + + cut.Instance.CsvMapping.Should().BeEmpty(); + cut.Instance.FileContentByte.Should().BeNull(); + expectedHeaders[0].MatchedFieldCount.Should().Be(0); + + var sourceItemsField = cut.Instance.GetType().GetField("_sourceItems", BindingFlags.NonPublic | BindingFlags.Instance)!; + sourceItemsField.GetValue(cut.Instance).Should().BeOfType>() + .Which.Should().BeEmpty(); + + var csvContentField = cut.Instance.GetType().GetField("CsvContent", BindingFlags.NonPublic | BindingFlags.Instance)!; + csvContentField.GetValue(cut.Instance).Should().BeNull(); + } + + [Test] + public void UpdateHeadersWithMappedFields_Should_Remap_And_Record_Mapping() + { + var cut = Context.Render(); + cut.Instance.ExpectedHeaders = new List { new("Name") }; + + var csvContent = new List> + { + new Dictionary { ["OriginalName"] = "Jane" } + }; + + SetPrivateMember(cut.Instance, "CsvContent", csvContent); + SetPrivateMember(cut.Instance, "_sourceItems", new List + { + new("OriginalName", "Name") + }); + InvokePrivate(cut.Instance, "UpdateHeadersWithMappedFields"); + cut.Instance.CsvMapping["Name"].Should().Be("OriginalName"); + csvContent[0].ContainsKey("Name").Should().BeTrue(); + csvContent[0].ContainsKey("OriginalName").Should().BeFalse(); + csvContent[0]["Name"].Should().Be("Jane"); + } private static void InvokePrivate(object instance, string methodName) { @@ -192,6 +274,28 @@ private static void InvokePrivate(object instance, string methodName) method!.Invoke(instance, null); } + private static void InvokePrivateWithArgs(object instance, string methodName, Type[] paramTypes, object[] args) + { + var method = instance.GetType() + .GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance, null, paramTypes, null); + + method.Should().NotBeNull($"method '{methodName}' not found"); + method!.Invoke(instance, args); + } + + private static async Task InvokePrivateAsync(object instance, string methodName) + { + var method = instance.GetType() + .GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); + + method.Should().NotBeNull(); + var result = method!.Invoke(instance, null); + if (result is Task task) + { + await task; + } + } + private static void SetPrivateMember(object instance, string name, object value) { var type = instance.GetType(); diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/DateTimePickerTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/DateTimePickerTests.cs index 0fec0d9d..b001a521 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/DateTimePickerTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/DateTimePickerTests.cs @@ -2,6 +2,9 @@ using Bunit; using Microsoft.AspNetCore.Components; +using System.Reflection; +using System.Linq; + namespace MudExtensions.UnitTests.Components; [TestFixture] @@ -98,4 +101,234 @@ public void DateTimePicker_Null_Value_Should_Render_Empty() var comp = RenderPicker(); comp.Find("input").GetAttribute("value").Should().BeNullOrEmpty(); } + + [Test] + public async Task DateTimePicker_DefaultDateTime_Should_Open_Current_Month() + { + await AssertDefaultValueOpensCurrentMonthAsync(); + } + + [Test] + public async Task DateTimePicker_DefaultDateOnly_Should_Open_Current_Month() + { + await AssertDefaultValueOpensCurrentMonthAsync(); + } + + [Test] + public async Task DateTimePicker_DefaultDateTimeOffset_WithIstanbulTimeZone_Should_Open_Current_Month() + { + var istanbulTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul"); + await AssertDefaultValueOpensCurrentMonthAsync(istanbulTimeZone); + } + + private async Task AssertDefaultValueOpensCurrentMonthAsync(TimeZoneInfo? timeZone = null) + { + var today = DateTime.Today; + var expectedMonth = new DateTime(today.Year, today.Month, 1); + var comp = Context.Render>(parameters => + { + parameters.Add(p => p.Value, default(T)); + + if (timeZone is not null) + parameters.Add(p => p.TimeZone, timeZone); + }); + + await InvokePickerOpenedAsync(comp.Instance); + + comp.Instance.PickerMonth.Should().Be(expectedMonth); + } + + private static Task InvokePickerOpenedAsync(MudDateTimePicker picker) + { + var method = typeof(MudBaseDatePickerX).GetMethod( + "OnPickerOpenedAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + + return (Task)method!.Invoke(picker, null)!; + } + + [Test] + public void BoundDateOrNull_DefaultNonNullable_ReturnsNull() + { + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, default(DateTime))); + var method = comp.Instance.GetType().GetMethod("BoundDateOrNull", BindingFlags.Instance | BindingFlags.NonPublic)!; + var result = (DateTime?)method.Invoke(comp.Instance, null); + result.Should().BeNull(); + } + + [Test] + public void BoundDateOrNull_ReturnsValue() + { + var date = new DateTime(2026, 5, 3); + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, date)); + var method = comp.Instance.GetType().GetMethod("BoundDateOrNull", BindingFlags.Instance | BindingFlags.NonPublic)!; + var result = (DateTime?)method.Invoke(comp.Instance, null); + result.Should().Be(date); + } + + [Test] + public void GetPickerHeaderDate_PrefersMinDate() + { + var min = new DateTime(2020, 1, 1); + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, null).Add(p => p.MinDate, min)); + var method = comp.Instance.GetType().GetMethod("GetPickerHeaderDate", BindingFlags.Instance | BindingFlags.NonPublic)!; + var result = (DateTime)method.Invoke(comp.Instance, new object?[] { null })!; + result.Should().Be(min.Date); + } + + [Test] + public void GetPickerHeaderDate_PrefersPickerMonth() + { + var pm = new DateTime(2021, 6, 1); + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, null)); + comp.Instance.PickerMonth = pm; + var method = comp.Instance.GetType().GetMethod("GetPickerHeaderDate", BindingFlags.Instance | BindingFlags.NonPublic)!; + var result = (DateTime)method.Invoke(comp.Instance, new object?[] { null })!; + result.Should().Be(pm); + } + + [Test] + public void GetPickerHeaderDate_FallbackToToday() + { + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, null)); + var method = comp.Instance.GetType().GetMethod("GetPickerHeaderDate", BindingFlags.Instance | BindingFlags.NonPublic)!; + var result = (DateTime)method.Invoke(comp.Instance, new object?[] { null })!; + result.Should().Be(DateTime.Today); + } + + [Test] + public void GetTitleDateString_StableDuringTimeEdits() + { + var value = new DateTime(2026, 5, 3, 10, 0, 0); + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, value)); + var methodTitle = comp.Instance.GetType().GetMethod("GetTitleDateString", BindingFlags.Instance | BindingFlags.NonPublic)!; + var before = (string)methodTitle.Invoke(comp.Instance, null)!; + + var field = comp.Instance.GetType().GetField("_workingValue", BindingFlags.Instance | BindingFlags.NonPublic)!; + field.SetValue(comp.Instance, new DateTime(2026, 5, 3, 22, 30, 0)); + + var after = (string)methodTitle.Invoke(comp.Instance, null)!; + after.Should().Be(before); + } + + [Test] + public async Task ClearDoesNotChangePickerMonth() + { + var initial = new DateTime(2022, 5, 1); + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, (DateTime?)initial)); + comp.Instance.PickerMonth = new DateTime(2022, 5, 1); + + var method = comp.Instance.GetType().GetMethod("SetDateAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + await comp.InvokeAsync(async () => + { + var t = (Task)method.Invoke(comp.Instance, new object?[] { null, true })!; + await t; + }); + + comp.Instance.PickerMonth.Should().Be(initial); + } + + [Test] + public async Task SyncTimeFromValue_Sets_Zero_When_WorkingValueNull() + { + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, null)); + var wfield = comp.Instance.GetType().GetField("_workingValue", BindingFlags.Instance | BindingFlags.NonPublic)!; + wfield.SetValue(comp.Instance, null); + var method = comp.Instance.GetType().GetMethod("SyncTimeFromValue", BindingFlags.Instance | BindingFlags.NonPublic)!; + await comp.InvokeAsync(() => method.Invoke(comp.Instance, null)); + var ts = comp.Instance.GetType().GetField("_timeSet", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(comp.Instance)!; + var hour = (int)ts.GetType().GetProperty("Hour")!.GetValue(ts)!; + var minute = (int)ts.GetType().GetProperty("Minute")!.GetValue(ts)!; + hour.Should().Be(0); + minute.Should().Be(0); + } + + [Test] + public async Task UpdateTimeAsync_Applies_TimeSet_To_WorkingValue() + { + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, null)); + var tsField = comp.Instance.GetType().GetField("_timeSet", BindingFlags.Instance | BindingFlags.NonPublic)!; + var ts = tsField.GetValue(comp.Instance)!; + ts.GetType().GetProperty("Hour")!.SetValue(ts, 13); + ts.GetType().GetProperty("Minute")!.SetValue(ts, 45); + var method = comp.Instance.GetType().GetMethod("UpdateTimeAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + await comp.InvokeAsync(async () => await (Task)method.Invoke(comp.Instance, null)!); + var w = (DateTime?)comp.Instance.GetType().GetField("_workingValue", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(comp.Instance); + w.Should().NotBeNull(); + w.Value.Hour.Should().Be(13); + w.Value.Minute.Should().Be(45); + } + + [Test] + public async Task SetTimePart_Sets_WorkingValue_Hour_Minute() + { + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, null)); + var method = comp.Instance.GetType().GetMethod("SetTimePart", BindingFlags.Instance | BindingFlags.NonPublic)!; + await comp.InvokeAsync(() => { method.Invoke(comp.Instance, new object?[] { 9, 15 }); return Task.CompletedTask; }); + var w = (DateTime?)comp.Instance.GetType().GetField("_workingValue", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(comp.Instance); + w.Should().NotBeNull(); + w.Value.Hour.Should().Be(9); + w.Value.Minute.Should().Be(15); + } + + [Test] + public async Task OnAmClickedAsync_And_OnPmClickedAsync_Adjust_Hour() + { + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, null)); + var tsField = comp.Instance.GetType().GetField("_timeSet", BindingFlags.Instance | BindingFlags.NonPublic)!; + var ts = tsField.GetValue(comp.Instance)!; + ts.GetType().GetProperty("Hour")!.SetValue(ts, 13); + var am = comp.Instance.GetType().GetMethod("OnAmClickedAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + await comp.InvokeAsync(async () => await (Task)am.Invoke(comp.Instance, null)!); + var hourAfterAm = (int)ts.GetType().GetProperty("Hour")!.GetValue(ts)!; + hourAfterAm.Should().Be(1); + + ts.GetType().GetProperty("Hour")!.SetValue(ts, 1); + var pm = comp.Instance.GetType().GetMethod("OnPmClickedAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + await comp.InvokeAsync(async () => await (Task)pm.Invoke(comp.Instance, null)!); + var hourAfterPm = (int)ts.GetType().GetProperty("Hour")!.GetValue(ts)!; + hourAfterPm.Should().Be(13); + } + + [Test] + public async Task SelectTimeFromStick_And_OnStickClick_Work() + { + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, null)); + // test minutes selection + var currentViewField = comp.Instance.GetType().GetField("CurrentView", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!; + var enumType = currentViewField.FieldType; + var hours = Enum.Parse(enumType, "Hours"); + var minutes = Enum.Parse(enumType, "Minutes"); + currentViewField.SetValue(comp.Instance, minutes); + await comp.InvokeAsync(async () => await comp.Instance.SelectTimeFromStick(30, true)); + var ts = comp.Instance.GetType().GetField("_timeSet", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(comp.Instance)!; + var minute = (int)ts.GetType().GetProperty("Minute")!.GetValue(ts)!; + minute.Should().Be(30); + comp.Instance.PointerMoving.Should().BeTrue(); + + // test hours selection and stick click behaviour + comp.Instance.GetType().GetField("CurrentView", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!.SetValue(comp.Instance, hours); + await comp.InvokeAsync(async () => await comp.Instance.SelectTimeFromStick(5, false)); + var hour = (int)ts.GetType().GetProperty("Hour")!.GetValue(ts)!; + hour.Should().Be(5); + + await comp.InvokeAsync(async () => await comp.Instance.OnStickClick(5)); + var currentView = comp.Instance.GetType().GetField("CurrentView", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(comp.Instance); + currentView.Should().Be(minutes); + } + + [Test] + public async Task SubmitAsync_Updates_Value() + { + var comp = Context.Render>(parameters => parameters.Add(p => p.Value, null)); + var wfield = comp.Instance.GetType().GetField("_workingValue", BindingFlags.Instance | BindingFlags.NonPublic)!; + wfield.SetValue(comp.Instance, new DateTime(2026, 6, 2, 11, 11, 0)); + var method = comp.Instance.GetType().GetMethod("SubmitAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + await comp.InvokeAsync(async () => await (Task)method.Invoke(comp.Instance, null)!); + var value = comp.Instance.Value; + value.Should().NotBeNull(); + var dt = (DateTime?)value; + dt.Value.Year.Should().Be(2026); + dt.Value.Hour.Should().Be(11); + } } diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/ListExtendedVirtualizationTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/ListExtendedVirtualizationTests.cs new file mode 100644 index 00000000..227909c7 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/ListExtendedVirtualizationTests.cs @@ -0,0 +1,66 @@ +using AwesomeAssertions; +using Bunit; +using Microsoft.AspNetCore.Components; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class ListExtendedVirtualizationTests : BunitTest + { + [Test] + public void ReusedListItem_ReappliesSelectionFromSelectedValues() + { + var selectedValues = new int?[] { 42 }; + var cut = RenderList(selectedValues, value: 1); + + var initialItem = cut.FindComponent>(); + initialItem.Instance.IsSelected.Should().BeFalse(); + + cut.Render(parameters => parameters + .Add(x => x.Clickable, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues) + .Add(x => x.ChildContent, RenderItem(42))); + + var reusedItem = cut.FindComponent>(); + ReferenceEquals(initialItem.Instance, reusedItem.Instance).Should().BeTrue(); + reusedItem.Instance.IsSelected.Should().BeTrue(); + } + + [Test] + public void MaterializedListItem_UsesConfiguredComparerForSelection() + { + var cut = Context.Render>(parameters => parameters + .Add(x => x.Clickable, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.Comparer, new AbsoluteValueComparer()) + .Add(x => x.SelectedValues, new int?[] { 42 }) + .Add(x => x.ChildContent, RenderItem(-42))); + + cut.FindComponent>().Instance.IsSelected.Should().BeTrue(); + } + + private IRenderedComponent> RenderList(IEnumerable selectedValues, int? value) + { + return Context.Render>(parameters => parameters + .Add(x => x.Clickable, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues) + .Add(x => x.ChildContent, RenderItem(value))); + } + + private static RenderFragment RenderItem(int? value) => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(MudListItemExtended.Value), value); + builder.CloseComponent(); + }; + + private sealed class AbsoluteValueComparer : IEqualityComparer + { + public bool Equals(int? x, int? y) => Math.Abs(x ?? 0) == Math.Abs(y ?? 0); + + public int GetHashCode(int? obj) => Math.Abs(obj ?? 0).GetHashCode(); + } + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/MudMapperTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/MudMapperTests.cs new file mode 100644 index 00000000..bb97bef0 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/MudMapperTests.cs @@ -0,0 +1,715 @@ +using AwesomeAssertions; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using MudBlazor; +using MudExtensions.Utilities; +using System.Reflection; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class MudMapperTests : BunitTest + { + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + [Test] + public void MudMapper_Should_Render_With_Minimal_Parameters() + { + var cut = Context.Render(); + + cut.Markup.Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public void MudMapper_Should_Render_TargetHeader_Names() + { + var headers = new List { new("FirstName"), new("LastName") }; + + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + cut.Markup.Should().Contain("FirstName"); + cut.Markup.Should().Contain("LastName"); + } + + [Test] + public void MudMapper_Should_Show_SourceItems_Zone_When_SourceItems_Provided() + { + var sourceItems = new List { new("ColA"), new("ColB") }; + + var cut = Context.Render(p => p.Add(x => x.SourceItems, sourceItems)); + + cut.Markup.Should().Contain("Source Items"); + } + + [Test] + public void MudMapper_Should_Not_Show_SourceItems_Zone_When_No_SourceItems() + { + var cut = Context.Render(); + + cut.Markup.Should().NotContain("Source Items"); + } + + [Test] + public void MudMapper_Should_Show_CreateHeader_Button_When_AllowCreateTargetHeaders_True() + { + var cut = Context.Render(p => p.Add(x => x.AllowCreateTargetHeaders, true)); + + cut.Markup.Should().Contain("Create Header"); + } + + [Test] + public void MudMapper_Should_Not_Show_CreateHeader_Button_By_Default() + { + var cut = Context.Render(); + + cut.Markup.Should().NotContain("Create Header"); + } + + [Test] + public void MudMapper_Should_Show_IncludeUnmappedData_Switch_When_Enabled() + { + var cut = Context.Render(p => p.Add(x => x.ShowIncludeUnmappedData, true)); + + cut.Markup.Should().Contain("Include unmapped data"); + } + + [Test] + public void MudMapper_Should_Not_Show_IncludeUnmappedData_Switch_By_Default() + { + var cut = Context.Render(); + + cut.Markup.Should().NotContain("Include unmapped data"); + } + + [Test] + public void MudMapper_Should_Use_ConfirmLabel_Parameter() + { + var cut = Context.Render(p => p.Add(x => x.ConfirmLabel, "Import CSV")); + + cut.Markup.Should().Contain("Import CSV"); + } + + [Test] + public void MudMapper_Should_Fall_Back_To_LocalizedStrings_When_No_ConfirmLabel() + { + var strings = new MudMapperLocalizedStrings { Confirm = "Apply Mapping" }; + + var cut = Context.Render(p => p.Add(x => x.LocalizedStrings, strings)); + + cut.Markup.Should().Contain("Apply Mapping"); + } + + [Test] + public void MudMapper_Should_Mark_Required_Headers_With_Asterisk() + { + var headers = new List { new("Id", required: true), new("Name") }; + + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + cut.Markup.Should().Contain("*"); + } + + [Test] + public void MudMapper_Should_Show_DefineHeaders_Text_When_No_TargetHeaders() + { + var strings = new MudMapperLocalizedStrings { DefineHeaders = "No headers defined yet" }; + + var cut = Context.Render(p => p.Add(x => x.LocalizedStrings, strings)); + + cut.Markup.Should().Contain("No headers defined yet"); + } + + [Test] + public void ItemSelector_Should_Return_True_For_Matching_Item() + { + var item = new MudMapperItem("col1", "Id"); + var method = typeof(MudMapper).GetMethod("ItemSelector", BindingFlags.NonPublic | BindingFlags.Static); + + var result = (bool)method!.Invoke(null, new object[] { item, "Id" })!; + + result.Should().BeTrue(); + } + + [Test] + public void IsSourcePoolItem_Should_Return_True_For_Source_Pool_Items() + { + var item = new MudMapperItem("col1", MudMapper.SourcePoolZoneIdentifier); + + MudMapper.IsSourcePoolItem(item).Should().BeTrue(); + } + + [Test] + public void OpenAddSection_Should_Set_Add_Section_Open() + { + var cut = Context.Render(); + + InvokePrivate(cut.Instance, "OpenAddSection"); + + cut.Instance.GetType() + .GetField("_addSectionOpen", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(cut.Instance) + .Should().Be(true); + } + + [Test] + public void OnSubmit_Should_Add_New_Target_Header_And_Default_Value() + { + var cut = Context.Render(); + SetPrivateMember(cut.Instance, "_model", new MudExpectedHeader("Age", required: false, allowDefaultValue: true)); + + InvokePrivateWithArgs(cut.Instance, "OnSubmit", + new[] { typeof(EditContext) }, + new object[] { new EditContext(new object()) }); + + cut.Instance.TargetHeaders.Should().ContainSingle(x => x.Name == "Age"); + cut.Instance.DefaultValues.Should().ContainKey("Age"); + } + + [Test] + public void OnIncludeUnmappedDataChanged_Should_Update_IncludeUnmappedData() + { + var cut = Context.Render(); + + InvokePrivateWithArgs(cut.Instance, "OnIncludeUnmappedDataChanged", + new[] { typeof(bool) }, + new object[] { true }); + + cut.Instance.IncludeUnmappedData.Should().BeTrue(); + } + + // ------------------------------------------------------------------------- + // Validity — CheckValid + // ------------------------------------------------------------------------- + + [Test] + public void CheckValid_Should_Be_False_When_Required_Header_Not_Mapped() + { + var headers = new List { new("Id", required: true) }; + var sourceItems = new List { new("col1") }; // MappedZone = "Source" + + var cut = Context.Render(p => p + .Add(x => x.TargetHeaders, headers) + .Add(x => x.SourceItems, sourceItems)); + + InvokePrivate(cut.Instance, "CheckValid"); + + cut.Instance.IsValid.Should().BeFalse(); + } + + [Test] + public void CheckValid_Should_Be_True_When_Required_Header_Is_Mapped() + { + var headers = new List { new("Id", required: true) }; + var sourceItems = new List { new("col1", "Id") }; // already mapped + headers[0].MatchedFieldCount = 1; + + var cut = Context.Render(p => p + .Add(x => x.TargetHeaders, headers) + .Add(x => x.SourceItems, sourceItems)); + + InvokePrivate(cut.Instance, "CheckValid"); + + cut.Instance.IsValid.Should().BeTrue(); + } + + [Test] + public void CheckValid_Should_Be_True_When_There_Are_No_Required_Headers() + { + var headers = new List { new("Name") }; // optional only + + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + InvokePrivate(cut.Instance, "CheckValid"); + + cut.Instance.IsValid.Should().BeTrue(); + } + + [Test] + public void CheckValid_Should_Be_True_When_Required_Header_Has_Confirmed_Default_Value() + { + var headers = new List { new("Age", required: true, allowDefaultValue: true) }; + + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + var defaults = new Dictionary + { + ["Age"] = new() { DefaultValue = "18", Confirmed = true } + }; + SetPrivateMember(cut.Instance, "_defaultValueHeaders", defaults); + + InvokePrivate(cut.Instance, "CheckValid"); + + cut.Instance.IsValid.Should().BeTrue(); + } + + [Test] + public void CheckValid_Should_Be_False_When_Required_Header_Has_Unconfirmed_Default_Value() + { + var headers = new List { new("Age", required: true, allowDefaultValue: true) }; + + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + var defaults = new Dictionary + { + ["Age"] = new() { DefaultValue = "18", Confirmed = false } + }; + SetPrivateMember(cut.Instance, "_defaultValueHeaders", defaults); + + InvokePrivate(cut.Instance, "CheckValid"); + + cut.Instance.IsValid.Should().BeFalse(); + } + + // ------------------------------------------------------------------------- + // OnDrop + // ------------------------------------------------------------------------- + + [Test] + public void OnDrop_Should_Update_Item_MappedZone_To_Target() + { + var headers = new List { new("Name") }; + var sourceItems = new List { new("col1") }; + + var cut = Context.Render(p => p + .Add(x => x.TargetHeaders, headers) + .Add(x => x.SourceItems, sourceItems)); + + SimulateDrop(cut.Instance, sourceItems[0], "Name"); + + sourceItems[0].MappedZone.Should().Be("Name"); + } + + [Test] + public void OnDrop_Should_Increment_MatchedFieldCount_On_Target_Header() + { + var headers = new List { new("Name") }; + var sourceItems = new List { new("col1") }; + + var cut = Context.Render(p => p + .Add(x => x.TargetHeaders, headers) + .Add(x => x.SourceItems, sourceItems)); + + SimulateDrop(cut.Instance, sourceItems[0], "Name"); + + headers[0].MatchedFieldCount.Should().Be(1); + } + + [Test] + public void OnDrop_Should_Decrement_Old_And_Increment_New_MatchedFieldCount() + { + var first = new MudExpectedHeader("First"); + var last = new MudExpectedHeader("Last"); + var headers = new List { first, last }; + var sourceItems = new List { new("col1", "First") }; // starts in "First" + first.MatchedFieldCount = 1; + + var cut = Context.Render(p => p + .Add(x => x.TargetHeaders, headers) + .Add(x => x.SourceItems, sourceItems)); + + SimulateDrop(cut.Instance, sourceItems[0], "Last"); + + first.MatchedFieldCount.Should().Be(0); + last.MatchedFieldCount.Should().Be(1); + } + + [Test] + public void OnDrop_To_Source_Should_Decrement_MatchedFieldCount() + { + var headers = new List { new("Name") }; + var sourceItems = new List { new("col1", "Name") }; // currently mapped + headers[0].MatchedFieldCount = 1; + + var cut = Context.Render(p => p + .Add(x => x.TargetHeaders, headers) + .Add(x => x.SourceItems, sourceItems)); + + SimulateDrop(cut.Instance, sourceItems[0], MudMapper.SourcePoolZoneIdentifier); + + headers[0].MatchedFieldCount.Should().Be(0); + sourceItems[0].MappedZone.Should().Be(MudMapper.SourcePoolZoneIdentifier); + } + + [Test] + public void OnDrop_Mapping_Required_Header_Should_Make_IsValid_True() + { + var headers = new List { new("Id", required: true) }; + var sourceItems = new List { new("col1") }; + + var cut = Context.Render(p => p + .Add(x => x.TargetHeaders, headers) + .Add(x => x.SourceItems, sourceItems)); + + SimulateDrop(cut.Instance, sourceItems[0], "Id"); + + cut.Instance.IsValid.Should().BeTrue(); + } + + [Test] + public void OnDrop_Unmapping_Required_Header_Should_Make_IsValid_False() + { + var headers = new List { new("Id", required: true) }; + var sourceItems = new List { new("col1", "Id") }; + headers[0].MatchedFieldCount = 1; + + var cut = Context.Render(p => p + .Add(x => x.TargetHeaders, headers) + .Add(x => x.SourceItems, sourceItems)); + + SimulateDrop(cut.Instance, sourceItems[0], "Source"); + + cut.Instance.IsValid.Should().BeFalse(); + } + + // ------------------------------------------------------------------------- + // Confirm / Reset + // ------------------------------------------------------------------------- + + [Test] + public async Task HandleConfirm_Should_Set_IsConfirmed_True() + { + var cut = Context.Render(); + SetPrivateMember(cut.Instance, "_valid", true); + + await cut.InvokeAsync(() => InvokePrivateAsync(cut.Instance, "HandleConfirm")); + + cut.Instance.IsConfirmed.Should().BeTrue(); + } + + [Test] + public async Task HandleConfirm_Should_Fire_OnConfirmed_Callback() + { + bool fired = false; + var cut = Context.Render(p => p + .Add(x => x.OnConfirmed, EventCallback.Factory.Create(this, () => fired = true))); + SetPrivateMember(cut.Instance, "_valid", true); + + await cut.InvokeAsync(() => InvokePrivateAsync(cut.Instance, "HandleConfirm")); + + fired.Should().BeTrue(); + } + + [Test] + public async Task HandleReset_Should_Clear_IsConfirmed() + { + var cut = Context.Render(); + SetPrivateMember(cut.Instance, "_valid", true); + await cut.InvokeAsync(() => InvokePrivateAsync(cut.Instance, "HandleConfirm")); + cut.Instance.IsConfirmed.Should().BeTrue(); + + await cut.InvokeAsync(() => InvokePrivateAsync(cut.Instance, "HandleReset")); + + cut.Instance.IsConfirmed.Should().BeFalse(); + } + + [Test] + public async Task HandleReset_Should_Fire_OnReset_Callback() + { + bool fired = false; + var cut = Context.Render(p => p + .Add(x => x.OnReset, EventCallback.Factory.Create(this, () => fired = true))); + + await cut.InvokeAsync(() => InvokePrivateAsync(cut.Instance, "HandleReset")); + + fired.Should().BeTrue(); + } + + // ------------------------------------------------------------------------- + // ResetMapping + // ------------------------------------------------------------------------- + + [Test] + public void ResetMapping_Should_Clear_IsConfirmed() + { + var cut = Context.Render(); + SetPrivateMember(cut.Instance, "_valid", true); + + cut.Instance.ResetMapping(); + + cut.Instance.IsConfirmed.Should().BeFalse(); + } + + [Test] + public void ResetMapping_Should_Clear_IsValid() + { + var headers = new List { new("Id", required: true) }; + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + SetPrivateMember(cut.Instance, "_valid", true); + + cut.Instance.ResetMapping(); + + cut.Instance.IsValid.Should().BeFalse(); + } + + [Test] + public void ResetMapping_Should_Reset_MatchedFieldCounts_On_TargetHeaders() + { + var headers = new List { new("Id"), new("Name") }; + headers[0].MatchedFieldCount = 2; + headers[1].MatchedFieldCount = 1; + + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + cut.Instance.ResetMapping(); + + headers[0].MatchedFieldCount.Should().Be(0); + headers[1].MatchedFieldCount.Should().Be(0); + } + + [Test] + public void ResetMapping_Should_Clear_IncludeUnmappedData() + { + var cut = Context.Render(p => p.Add(x => x.ShowIncludeUnmappedData, true)); + cut.Instance.ResetMapping(); + + cut.Instance.IncludeUnmappedData.Should().BeFalse(); + } + + [Test] + public void ResetMapping_Should_Reinitialize_DefaultValues() + { + var headers = new List + { + new("Age", required: true, allowDefaultValue: true) + }; + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + // Simulate a confirmed default + var confirmedDefaults = new Dictionary + { + ["Age"] = new() { DefaultValue = "99", Confirmed = true } + }; + SetPrivateMember(cut.Instance, "_defaultValueHeaders", confirmedDefaults); + + cut.Instance.ResetMapping(); + + cut.Instance.DefaultValues.Should().NotBeNull(); + cut.Instance.DefaultValues!["Age"].Confirmed.Should().BeFalse(); + cut.Instance.DefaultValues!["Age"].DefaultValue.Should().Be(""); + } + + // ------------------------------------------------------------------------- + // Default values + // ------------------------------------------------------------------------- + + [Test] + public void DefaultValues_Should_Be_Initialized_For_AllowDefaultValue_Headers() + { + var headers = new List + { + new("Age", required: true, allowDefaultValue: true), + new("Name") // no default value allowed + }; + + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + cut.Instance.DefaultValues.Should().NotBeNull(); + cut.Instance.DefaultValues!.ContainsKey("Age").Should().BeTrue(); + cut.Instance.DefaultValues!.ContainsKey("Name").Should().BeFalse(); + } + + [Test] + public void DefaultValues_Should_Be_Empty_When_No_AllowDefaultValue_Headers() + { + var headers = new List { new("Id"), new("Name") }; + + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + cut.Instance.DefaultValues.Should().NotBeNull(); + cut.Instance.DefaultValues!.Should().BeEmpty(); + } + + [Test] + public void SubmitDefaultValue_Should_Confirm_When_Value_Is_Provided() + { + var headers = new List { new("Age", required: true, allowDefaultValue: true) }; + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + var defaults = new Dictionary + { + ["Age"] = new() { DefaultValue = "25", Confirmed = false } + }; + SetPrivateMember(cut.Instance, "_defaultValueHeaders", defaults); + + InvokePrivateWithArgs(cut.Instance, "SubmitDefaultValue", + new[] { typeof(string) }, + new object[] { "Age" }); + + defaults["Age"].Confirmed.Should().BeTrue(); + } + + [Test] + public void SubmitDefaultValue_Should_Not_Confirm_When_Value_Is_Empty() + { + var headers = new List { new("Age", required: true, allowDefaultValue: true) }; + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + var defaults = new Dictionary + { + ["Age"] = new() { DefaultValue = "", Confirmed = false } + }; + SetPrivateMember(cut.Instance, "_defaultValueHeaders", defaults); + + InvokePrivateWithArgs(cut.Instance, "SubmitDefaultValue", + new[] { typeof(string) }, + new object[] { "Age" }); + + defaults["Age"].Confirmed.Should().BeFalse(); + } + + [Test] + public void SubmitDefaultValue_Should_Toggle_Off_When_Already_Confirmed() + { + var headers = new List { new("Age", required: true, allowDefaultValue: true) }; + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + var defaults = new Dictionary + { + ["Age"] = new() { DefaultValue = "25", Confirmed = true } + }; + SetPrivateMember(cut.Instance, "_defaultValueHeaders", defaults); + + InvokePrivateWithArgs(cut.Instance, "SubmitDefaultValue", + new[] { typeof(string) }, + new object[] { "Age" }); + + defaults["Age"].Confirmed.Should().BeFalse(); + } + + [Test] + public void SubmitDefaultValue_For_Required_Header_Should_Make_IsValid_True() + { + var headers = new List { new("Age", required: true, allowDefaultValue: true) }; + var cut = Context.Render(p => p.Add(x => x.TargetHeaders, headers)); + + var defaults = new Dictionary + { + ["Age"] = new() { DefaultValue = "18", Confirmed = false } + }; + SetPrivateMember(cut.Instance, "_defaultValueHeaders", defaults); + + InvokePrivateWithArgs(cut.Instance, "SubmitDefaultValue", + new[] { typeof(string) }, + new object[] { "Age" }); + + cut.Instance.IsValid.Should().BeTrue(); + } + + [Test] + public void Parameters_Should_Rebuild_MatchedFieldCounts_From_Current_SourceZones() + { + var headers = new List { new("First"), new("Last") }; + var sourceItems = new List + { + new("col1", "First"), + new("col2", "Source"), + new("col3", "DoesNotExist") + }; + + var cut = Context.Render(p => p + .Add(x => x.TargetHeaders, headers) + .Add(x => x.SourceItems, sourceItems)); + + headers[0].MatchedFieldCount.Should().Be(1); + headers[1].MatchedFieldCount.Should().Be(0); + sourceItems[2].MappedZone.Should().Be(MudMapper.SourcePoolZoneIdentifier); + } + + [Test] + public void OnDrop_Should_Recalculate_MatchedFieldCounts_For_New_Target() + { + var headers = new List { new("First"), new("Last") }; + var sourceItems = new List { new("col1", MudMapper.SourcePoolZoneIdentifier) }; + var cut = Context.Render(p => p + .Add(x => x.TargetHeaders, headers) + .Add(x => x.SourceItems, sourceItems)); + + SimulateDrop(cut.Instance, sourceItems[0], "First"); + headers[0].MatchedFieldCount.Should().Be(1); + headers[1].MatchedFieldCount.Should().Be(0); + + SimulateDrop(cut.Instance, sourceItems[0], "Last"); + headers[0].MatchedFieldCount.Should().Be(0); + headers[1].MatchedFieldCount.Should().Be(1); + } + + [Test] + public void SourcePool_Items_Should_Not_Be_Treated_As_Target_Header_Name_Source() + { + var headers = new List { new("Source") }; + var sourceItems = new List { new("col1", MudMapper.SourcePoolZoneIdentifier) }; + + var cut = Context.Render(p => p + .Add(x => x.TargetHeaders, headers) + .Add(x => x.SourceItems, sourceItems)); + + headers[0].MatchedFieldCount.Should().Be(0); + sourceItems[0].MappedZone.Should().Be(MudMapper.SourcePoolZoneIdentifier); + cut.Instance.IsValid.Should().BeTrue(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static void SimulateDrop(MudMapper instance, MudMapperItem item, string targetZone) + { + var dropInfo = new MudItemDropInfo(item, targetZone, 0); + InvokePrivateWithArgs(instance, "OnDrop", + new[] { typeof(MudItemDropInfo) }, + new object[] { dropInfo }); + } + + private static void InvokePrivate(object instance, string methodName) + { + var method = instance.GetType() + .GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); + + method.Should().NotBeNull($"private method '{methodName}' should exist"); + method!.Invoke(instance, null); + } + + private static async Task InvokePrivateAsync(object instance, string methodName) + { + var method = instance.GetType() + .GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); + + method.Should().NotBeNull($"private async method '{methodName}' should exist"); + var task = (Task)method!.Invoke(instance, null)!; + await task; + } + + private static void InvokePrivateWithArgs(object instance, string methodName, Type[] paramTypes, object[] args) + { + var method = instance.GetType() + .GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance, null, paramTypes, null); + + method.Should().NotBeNull($"method '{methodName}' with specified parameter types should exist"); + method!.Invoke(instance, args); + } + + private static void SetPrivateMember(object instance, string name, object value) + { + var type = instance.GetType(); + + var field = type.GetField(name, BindingFlags.NonPublic | BindingFlags.Instance); + if (field != null) + { + field.SetValue(instance, value); + return; + } + + var prop = type.GetProperty(name, BindingFlags.NonPublic | BindingFlags.Instance); + if (prop != null) + { + prop.SetValue(instance, value); + return; + } + + Assert.Fail($"Private member '{name}' not found on {type.Name}"); + } + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedItemCollectionRefreshTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedItemCollectionRefreshTests.cs new file mode 100644 index 00000000..8f68e716 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedItemCollectionRefreshTests.cs @@ -0,0 +1,27 @@ +using AwesomeAssertions; +using Bunit; +using MudExtensions.UnitTests.TestComponents; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class SelectExtendedItemCollectionRefreshTests : BunitTest + { + [Test] + public async Task ItemCollectionMutation_WhileOpen_RendersNewItemsWithoutReopening() + { + var cut = Context.Render(); + + cut.Find("div.mud-input-control").Click(); + cut.WaitForAssertion(() => + cut.FindAll("div.mud-list-item-extended").Should().HaveCount(5)); + + await cut.InvokeAsync(cut.Instance.AddMore); + + // The same ItemCollection instance was mutated while the popover remained open. + // Hosted MudListExtended previously discarded this parameter update entirely (#645). + cut.WaitForAssertion(() => + cut.FindAll("div.mud-list-item-extended").Should().HaveCount(10)); + } + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedPresentationStateTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedPresentationStateTests.cs new file mode 100644 index 00000000..914b5933 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedPresentationStateTests.cs @@ -0,0 +1,77 @@ +using AwesomeAssertions; +using Bunit; +using Microsoft.AspNetCore.Components; +using MudExtensions.UnitTests.TestComponents; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class SelectExtendedPresentationStateTests : BunitTest + { + [Test] + public void DeclarativeItems_RegisterMetadataWithoutHiddenList() + { + RenderFragment childContent = builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(MudSelectItemExtended.Value), "two"); + builder.AddAttribute(2, nameof(MudSelectItemExtended.Text), "Friendly two"); + builder.CloseComponent(); + }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.Value, "two") + .Add(x => x.ChildContent, childContent)); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("Friendly two")); + + cut.Instance.Items.Should().ContainSingle(); + cut.FindComponents>().Should().BeEmpty(); + cut.FindComponents>().Should().ContainSingle(); + } + + + [Test] + public async Task ItemCollection_SelectedObjectMutation_RefreshesClosedPresentation() + { + var cut = Context.Render(); + + cut.Find("input").GetAttribute("value").Should().Be("Two"); + + await cut.InvokeAsync(() => cut.Instance.RenameSelected("Two updated")); + + cut.WaitForAssertion(() => + cut.Find("input").GetAttribute("value").Should().Be("Two updated")); + } + + [Test] + public async Task DeclarativeSelectedItemTextMutation_RefreshesClosedPresentation() + { + var cut = Context.Render(); + + cut.Find("input").GetAttribute("value").Should().Be("One"); + + await cut.InvokeAsync(() => cut.Instance.RenameSelectedItem("One updated")); + + cut.WaitForAssertion(() => + cut.Find("input").GetAttribute("value").Should().Be("One updated")); + } + + [Test] + public async Task ItemCollection_SelectOptionByIndex_UsesLogicalCollection() + { + var items = new List { "one", "two", "three" }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true)); + + await cut.InvokeAsync(() => cut.Instance.SelectOption(1)); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("two")); + cut.Instance.Items.Should().BeEmpty(); + } + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedTests.cs index 42aeabed..735456cc 100644 --- a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedTests.cs +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedTests.cs @@ -548,8 +548,8 @@ public void Disabled_SelectItem_Should_Be_Respected() var selectElement = comp.Find("div.mud-input-control"); selectElement.Click(); - // Disabled item found twice because we have 2 shadow lists. Need to be discussed later - comp.WaitForAssertion(() => comp.FindAll("div.mud-list-item-disabled-extended").Count.Should().Be(2)); + // The disabled option should exist once in the visible list; there is no hidden duplicate tree. + comp.WaitForAssertion(() => comp.FindAll("div.mud-list-item-disabled-extended").Count.Should().Be(1)); comp.FindAll("div.mud-list-item-disabled-extended")[0].Click(); comp.WaitForAssertion(() => select.Instance.GetState(x => x.Value).Should().BeNull()); } @@ -946,7 +946,7 @@ public void Select_Should_HilightInitiallySelectedValue() //Console.WriteLine(comp.Markup); comp.WaitForAssertion(() => comp.Find("div.mud-popover").ClassList.Should().Contain("mud-popover-open")); // Nr 2 should be hilited - comp.WaitForAssertion(() => comp.FindAll("div.mud-selected-item").Count.Should().Be(2)); + comp.WaitForAssertion(() => comp.FindAll("div.mud-selected-item").Count.Should().Be(1)); comp.FindAll("div.mud-list-item-extended")[1].ToMarkup().Should().Contain("mud-selected-item"); // now click an item and see the value change comp.FindAll("div.mud-list-item-extended")[0].Click(); @@ -956,7 +956,7 @@ public void Select_Should_HilightInitiallySelectedValue() comp.Find("div.mud-input-control").Click(); comp.WaitForAssertion(() => comp.Find("div.mud-popover").ClassList.Should().Contain("mud-popover-open")); // Nr 1 should be hilited - comp.WaitForAssertion(() => comp.FindAll("div.mud-selected-item").Count.Should().Be(2)); + comp.WaitForAssertion(() => comp.FindAll("div.mud-selected-item").Count.Should().Be(1)); comp.FindAll("div.mud-list-item-extended")[0].ToMarkup().Should().Contain("mud-selected-item"); comp.Find("div.mud-input-control").Click(); comp.WaitForAssertion(() => comp.Find("div.mud-popover").ClassList.Should().NotContain("mud-popover-open")); diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs new file mode 100644 index 00000000..611bf280 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationScaleTests.cs @@ -0,0 +1,50 @@ +using AwesomeAssertions; +using Bunit; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class SelectExtendedVirtualizationScaleTests : BunitTest + { + [TestCase(10)] + [TestCase(100)] + [TestCase(1_000)] + [TestCase(4_000)] + public void VirtualizedItemCollection_DoesNotMaterializeShadowList(int itemCount) + { + var items = Enumerable.Range(1, itemCount).Select(value => (int?)value).ToList(); + var selectedValues = new int?[] { 1, itemCount }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues)); + + cut.FindComponents>().Should().BeEmpty(); + cut.Instance.Items.Should().BeEmpty(); + cut.Instance.SelectedValues.Should().BeEquivalentTo(selectedValues); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be($"1, {itemCount}")); + } + + [Test] + public void NonVirtualizedItemCollection_IsAlsoValueDrivenWhileClosed() + { + var items = Enumerable.Range(1, 100).Select(value => (int?)value).ToList(); + var selectedValues = new int?[] { 1, 100 }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, false) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues)); + + cut.Instance.Items.Should().BeEmpty(); + cut.FindComponents>().Should().BeEmpty(); + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("1, 100")); + } + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs new file mode 100644 index 00000000..0fdc0dc3 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationSingleSelectionTests.cs @@ -0,0 +1,49 @@ +using AwesomeAssertions; +using Bunit; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class SelectExtendedVirtualizationSingleSelectionTests : BunitTest + { + [Test] + public void VirtualizedItemCollection_InitializedSingleSelectionDoesNotNeedShadowItem() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.Value, 3_999)); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("3999")); + + cut.FindComponents>().Should().BeEmpty(); + cut.Instance.Items.Should().BeEmpty(); + } + + [Test] + public void VirtualizedItemCollection_OffscreenValueSupportsItemContentPresenter() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + RenderFragment> template = item => builder => + builder.AddContent(0, $"Selected item {item.Value}"); + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.ValuePresenter, ValuePresenter.ItemContent) + .Add(x => x.ItemTemplate, template) + .Add(x => x.Value, 3_999)); + + cut.WaitForAssertion(() => + cut.Markup.Should().Contain("Selected item 3999")); + + cut.FindComponents>().Should().BeEmpty(); + cut.Instance.Items.Should().BeEmpty(); + } + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs new file mode 100644 index 00000000..8c3d1675 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationStrictTests.cs @@ -0,0 +1,43 @@ +using AwesomeAssertions; +using Bunit; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class SelectExtendedVirtualizationStrictTests : BunitTest + { + [Test] + public void StrictMode_ValidOffscreenInitialValue_RemainsRepresentable() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.Strict, true) + .Add(x => x.Value, 3_999)); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("3999")); + + cut.FindComponents>().Should().BeEmpty(); + cut.Instance.Items.Should().BeEmpty(); + } + + [Test] + public void StrictMode_ValueOutsideItemCollection_RemainsUnrepresentableWithoutShadowList() + { + var items = Enumerable.Range(1, 100).Select(value => (int?)value).ToList(); + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.Strict, true) + .Add(x => x.Value, 999)); + + cut.FindComponents>().Should().BeEmpty(); + cut.Instance.Items.Should().BeEmpty(); + cut.Find("input").GetAttribute("value").Should().BeNullOrEmpty(); + } + } +} diff --git a/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs new file mode 100644 index 00000000..62f327b5 --- /dev/null +++ b/tests/CodeBeam.MudBlazor.Extensions.UnitTests/Components/SelectExtendedVirtualizationTests.cs @@ -0,0 +1,106 @@ +using AwesomeAssertions; +using Bunit; + +namespace MudExtensions.UnitTests.Components +{ + [TestFixture] + public class SelectExtendedVirtualizationTests : BunitTest + { + [Test] + public void VirtualizedItemCollection_SelectionDoesNotRequireHiddenItems() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + var selectedValues = new int?[] { 17, 3_999 }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues)); + + cut.FindComponents>().Should().BeEmpty(); + cut.Instance.Items.Should().BeEmpty(); + cut.Instance.SelectedValues.Should().BeEquivalentTo(selectedValues); + } + + [Test] + public void VirtualizedItemCollection_InitializedMultiSelectionStillProducesText() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + var selectedValues = new int?[] { 17, 3_999 }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues)); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("17, 3999")); + } + + [Test] + public void VirtualizedItemCollection_ChangedSelectionUpdatesTextWithoutShadowItems() + { + var items = Enumerable.Range(1, 4_000).Select(value => (int?)value).ToList(); + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, new int?[] { 17 })); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("17")); + + cut.Render(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, new int?[] { 3_999 })); + + cut.WaitForAssertion(() => + cut.Instance.SelectedValues.Should().BeEquivalentTo(new int?[] { 3_999 })); + + cut.FindComponents>().Should().BeEmpty(); + cut.Instance.Items.Should().BeEmpty(); + + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("3999")); + } + + [Test] + public void VirtualizedItemCollection_ValuePresentationRespectsComparer() + { + var items = new List + { + new(1, "One"), + new(2, "Two"), + new(3, "Three") + }; + var selectedValues = new TestValue?[] { new(2, "Different instance") }; + + var cut = Context.Render>(parameters => parameters + .Add(x => x.ItemCollection, items) + .Add(x => x.Virtualize, true) + .Add(x => x.MultiSelection, true) + .Add(x => x.SelectedValues, selectedValues) + .Add(x => x.Comparer, new TestValueComparer()) + .Add(x => x.ToStringFunc, value => value?.Name)); + + cut.FindComponents>().Should().BeEmpty(); + cut.Instance.Items.Should().BeEmpty(); + cut.WaitForAssertion(() => + cut.Find("input").Attributes["value"]?.Value.Should().Be("Two")); + } + + private sealed record TestValue(int Id, string Name); + + private sealed class TestValueComparer : IEqualityComparer + { + public bool Equals(TestValue? x, TestValue? y) => x?.Id == y?.Id; + + public int GetHashCode(TestValue? obj) => obj?.Id.GetHashCode() ?? 0; + } + } +}