@* * 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]; } }