@*
* 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.
*@
@code {
[Parameter] public IReadOnlyCollection AllKeys { get; set; } = null!;
[Parameter] public string[] Value { get; set; } = [];
[Parameter] public EventCallback 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 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 Items
{
get;
set
{
if (!ReferenceEquals(value, field))
{
if (Same(value, field))
return;
field = value;
}
OnChanged(null, false);
}
} = [];
private List 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 SyncToItems(string[] value)
{
var valueSet = new HashSet(value);
var allSet = new HashSet(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 v1, List 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 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 ReorderItems(
IReadOnlyList items,
IReadOnlyList filteredItems
)
{
try
{
var result = ReorderItemsUnsafe(items, filteredItems);
return result;
}
catch (Exception)
{
if (items is List l)
return l;
return [..items];
}
}
///
/// 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.
///
///
/// 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.
///
///
internal static List ReorderItemsUnsafe(
IReadOnlyList items,
IReadOnlyList filteredItems
)
{
if (items == null) throw new ArgumentNullException(nameof(items));
if (filteredItems.Count == 0)
{
if (items is List l)
return l;
return [.. items];
}
var indexMap = new Dictionary(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(filteredItems);
var othersInSegment = segment.Where(x => !filteredSet.Contains(x)).ToList();
var resultSegment = new List(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];
}
}