dbMango/Rms.Risk.Mango.Pivot.UI/Pivot/PivotKeysComponent.razor
Alexander Shabarshov 2a7a24c9e7 Initial contribution
2025-11-03 14:43:26 +00:00

324 lines
10 KiB
Plaintext

@*
* dbMango
*
* Copyright 2025 Deutsche Bank AG
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*@
<div class="@Class">
<style>
.z-auto {
z-index: auto !important;
}
</style>
<div class="w-100 pl-2 pr-2 mb-2">
<input class="form-control z-auto" type="search" placeholder="Search..." @bind-value="SearchString" @bind-value:event="oninput" />
</div>
<DragDropList TNode="ItemWithSelection" Class="" @bind-Value="FilteredItems">
<ItemTemplate>
<div class="flex-stack-horizontal">
<label class="input-group-text w-25 @LabelClass" style="min-width: 200px;">@context.Text</label>
<label class="toggle-switch toggle-switch-xs input-group-text" style="display: inline-block">
<input type="checkbox" @bind="context.IsSelected" />
<span class="slider" data-label-on="on" data-label-off="off"></span>
</label>
</div>
</ItemTemplate>
</DragDropList>
</div>
@code {
[Parameter] public IReadOnlyCollection<string> AllKeys { get; set; } = null!;
[Parameter] public string[] Value { get; set; } = [];
[Parameter] public EventCallback<string[]> ValueChanged { get; set; }
[Parameter] public string Class { get; set; } = "";
[Parameter] public string LabelClass { get; set; } = "";
internal class ItemWithSelection
{
public required string Text { get; init; }
public bool IsSelected
{
get;
set
{
if (field == value)
return;
field = value;
// despite 'init' this happens right at the init stage too, so guard against null.
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
Changed?.Invoke(Text, IsSelected);
}
}
public required Action<string, bool> Changed { get; init; }
public override string ToString() => $"[{(IsSelected ? "X" : "")}] {Text}";
public override bool Equals(object? obj)
{
if (obj is not ItemWithSelection other)
return false;
return IsSelected == other.IsSelected && Text.Equals(other.Text, StringComparison.Ordinal);
}
public override int GetHashCode() => HashCode.Combine(Text);
}
private List<ItemWithSelection> Items
{
get;
set
{
if (!ReferenceEquals(value, field))
{
if (Same(value, field))
return;
field = value;
}
OnChanged(null, false);
}
} = [];
private List<ItemWithSelection> FilteredItems
{
get;
set
{
if (!ReferenceEquals(value, field))
{
if (Same(value, field))
return;
field = value;
if (!ReferenceEquals(field, Items))
Items = ReorderItems(Items, field);
}
}
} = [];
private string SearchString
{
get;
set
{
if (field == value)
return;
field = value;
ApplySearch();
}
} = "";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
var v = SyncToItems(Value);
if (!Same(v, Items))
{
Items = v;
ApplySearch();
await InvokeAsync(StateHasChanged);
}
await base.OnAfterRenderAsync(firstRender);
}
private List<ItemWithSelection> SyncToItems(string[] value)
{
var valueSet = new HashSet<string>(value);
var allSet = new HashSet<string>(AllKeys);
var selected = value
.Where(x => allSet.Contains(x))
.Select(x => new ItemWithSelection {Text = x, IsSelected = true, Changed = OnChanged})
;
var theRest = AllKeys
.Where(x => !valueSet.Contains(x))
.OrderBy(x => x)
.Select(x => new ItemWithSelection {Text = x, IsSelected = false, Changed = OnChanged})
;
return [..selected.Concat(theRest)];
}
private void OnChanged(string? _, bool __)
{
var v = SyncToValue(Items);
if (Same(v, Value))
return;
Value = v;
ValueChanged.InvokeAsync(Value);
}
private static bool Same(string[] v1, string[] v2)
{
if (v1.Length != v2.Length)
return false;
return !v1.Where((t, i) => t != v2[i]).Any();
}
private static bool Same(List<ItemWithSelection> v1, List<ItemWithSelection> v2)
{
if (v1.Count != v2.Count)
return false;
return !v1.Where((t, i) => t.Text != v2[i].Text || t.IsSelected != v2[i].IsSelected).Any();
}
private static string [] SyncToValue(List<ItemWithSelection> items)
{
var selected = items
.Where(x => x.IsSelected)
.Select(x => x.Text)
.ToArray()
;
return selected;
}
private void ApplySearch()
{
if (string.IsNullOrWhiteSpace(SearchString))
{
if (ReferenceEquals(FilteredItems, Items) )
return;
Items = ReorderItems(Items, FilteredItems);
FilteredItems = Items;
return;
}
FilteredItems = Items
.Where(x => x.Text.Contains(SearchString, StringComparison.OrdinalIgnoreCase))
.ToList();
InvokeAsync(StateHasChanged);
}
private static List<ItemWithSelection> ReorderItems(
IReadOnlyList<ItemWithSelection> items,
IReadOnlyList<ItemWithSelection> filteredItems
)
{
try
{
var result = ReorderItemsUnsafe(items, filteredItems);
return result;
}
catch (Exception)
{
if (items is List<ItemWithSelection> l)
return l;
return [..items];
}
}
/// <summary>
/// Reorders 'items' so that the subset 'filteredItems' appears in the given (new) order,
/// while preserving the relative order of all other items.
/// Fix: Handles backward moves without prematurely emitting intervening others.
/// </summary>
/// <remarks>
/// Generated by GPT-5. Tests are in Tests.Rms.Risk.Mango.PivotKeysComponentTests.
///
/// Envelope-based algorithm:
///
/// 1. Determine slice spanning min/max original indices of filtered items.
/// 2. Reorder only within that slice.
/// 3. Defer emitting "others" when a filtered item moves backward, preventing premature placement (fixing failing backward-move test).
/// 4. Emit others only between forward-moving adjacent filtered items; append remaining others at end of slice. Preserves relative order of non-filtered items and enforces new filtered order.
///
/// </remarks>
internal static List<ItemWithSelection> ReorderItemsUnsafe(
IReadOnlyList<ItemWithSelection> items,
IReadOnlyList<ItemWithSelection> filteredItems
)
{
if (items == null) throw new ArgumentNullException(nameof(items));
if (filteredItems.Count == 0)
{
if (items is List<ItemWithSelection> l)
return l;
return [.. items];
}
var indexMap = new Dictionary<ItemWithSelection, int>(items.Count);
for (var i = 0; i < items.Count; i++)
indexMap[items[i]] = i;
// Validate subset.
if (filteredItems.Any(f => !indexMap.ContainsKey(f)))
throw new ArgumentException("filteredItems must be a strict subset of items.", nameof(filteredItems));
// Envelope covering all filtered original indices.
var minIndex = filteredItems.Min(f => indexMap[f]);
var maxIndex = filteredItems.Max(f => indexMap[f]);
// Slice.
var segment = items.Skip(minIndex).Take(maxIndex - minIndex + 1).ToList();
var filteredSet = new HashSet<ItemWithSelection>(filteredItems);
var othersInSegment = segment.Where(x => !filteredSet.Contains(x)).ToList();
var resultSegment = new List<ItemWithSelection>(segment.Count);
var othersPos = 0;
var prevFilteredOrigIndex = -1;
foreach (var f in filteredItems)
{
var currIndex = indexMap[f];
if (prevFilteredOrigIndex != -1 && currIndex > prevFilteredOrigIndex)
{
// Forward move: emit others whose original index is strictly between previous and current filtered.
while (othersPos < othersInSegment.Count)
{
var o = othersInSegment[othersPos];
var oIdx = indexMap[o];
if (oIdx > prevFilteredOrigIndex && oIdx < currIndex)
{
resultSegment.Add(o);
othersPos++;
}
else if (oIdx <= prevFilteredOrigIndex)
{
// Already passed earlier range; skip (should not happen with ordered list, but safe).
othersPos++;
}
else
break;
}
}
// Backward move: do not emit intervening others now.
resultSegment.Add(f);
prevFilteredOrigIndex = currIndex;
}
// Append remaining others (those not yet emitted).
while (othersPos < othersInSegment.Count)
resultSegment.Add(othersInSegment[othersPos++]);
// Reconstruct full list: before segment + new segment + after segment.
var before = items.Take(minIndex);
var after = items.Skip(maxIndex + 1);
return [.. before, .. resultSegment, .. after];
}
}