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..3c50983c 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 @@ -3552,6 +3552,182 @@ 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. + + + + 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. + + + + + + + + Resets mapping state so the component can be reused for a new set of source items. + + @@ -7189,6 +7365,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/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/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 f13e14c0..d17ed2fa 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 f42dec5f..c0155f24 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/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..b3af5467 --- /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 == requiredHeader.Name)) continue; + if (_defaultValueHeaders?.Any(x => x.Key == requiredHeader.Name && 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/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/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/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}"); + } + } +}