From 4232de35465fe6e439e913c43f98e883e21c948e Mon Sep 17 00:00:00 2001 From: Natalia Portillo Date: Mon, 29 Jun 2026 12:37:02 +0100 Subject: [PATCH] Add software compilation merge to Blazor and Uno admin apps Lets admins merge duplicate SoftwareCompilation entries: a new POST /software-compilations/{id}/merge endpoint re-parents releases, covers, junction rows (with dedup/self-loop guards), and predecessor references onto the target before deleting the sources. Wired into both the Blazor MudBlazor admin page and the Uno admin page, each with a search-based target/source picker, plus full localization. --- .../MergeSoftwareCompilationsRequest.cs | 69 +++++++ .../Item/Merge/MergeRequestBuilder.cs | 102 ++++++++++ .../SoftwareCompilationsItemRequestBuilder.cs | 6 + Marechai.ApiClient/kiota-lock.json | 2 +- .../AdminSoftwareCompilationsViewModel.cs | 143 ++++++++++++++ .../Admin/AdminSoftwareCompilationsPage.xaml | 96 ++++++++++ .../AdminSoftwareCompilationsPage.xaml.cs | 35 ++++ Marechai.App/Resources/Resources.de.resx | 18 ++ Marechai.App/Resources/Resources.es.resx | 18 ++ Marechai.App/Resources/Resources.fr.resx | 18 ++ Marechai.App/Resources/Resources.pt-BR.resx | 18 ++ Marechai.App/Resources/Resources.resx | 18 ++ .../Services/SoftwareCompilationsService.cs | 23 +++ .../Dtos/MergeSoftwareCompilationsRequest.cs | 41 ++++ .../SoftwareCompilationsController.cs | 175 ++++++++++++++++++ .../MergeSoftwareCompilationsDialog.razor | 73 ++++++++ .../MergeSoftwareCompilationsDialog.razor.cs | 67 +++++++ .../MergeSoftwareCompilationsDialogResult.cs | 9 + .../Pages/Admin/SoftwareCompilations.razor | 1 + .../Pages/Admin/SoftwareCompilations.razor.cs | 27 +++ .../SoftwareCompilationsService.de.resx | 95 ++++++++++ .../SoftwareCompilationsService.es.resx | 95 ++++++++++ .../SoftwareCompilationsService.fr.resx | 95 ++++++++++ .../SoftwareCompilationsService.it.resx | 95 ++++++++++ .../Services/SoftwareCompilationsService.cs | 24 +++ 25 files changed, 1362 insertions(+), 1 deletion(-) create mode 100644 Marechai.ApiClient/Models/MergeSoftwareCompilationsRequest.cs create mode 100644 Marechai.ApiClient/SoftwareCompilations/Item/Merge/MergeRequestBuilder.cs create mode 100644 Marechai.Data/Dtos/MergeSoftwareCompilationsRequest.cs create mode 100644 Marechai/Pages/Admin/MergeSoftwareCompilationsDialog.razor create mode 100644 Marechai/Pages/Admin/MergeSoftwareCompilationsDialog.razor.cs create mode 100644 Marechai/Pages/Admin/MergeSoftwareCompilationsDialogResult.cs create mode 100644 Marechai/Resources/Services/SoftwareCompilationsService.de.resx create mode 100644 Marechai/Resources/Services/SoftwareCompilationsService.es.resx create mode 100644 Marechai/Resources/Services/SoftwareCompilationsService.fr.resx create mode 100644 Marechai/Resources/Services/SoftwareCompilationsService.it.resx diff --git a/Marechai.ApiClient/Models/MergeSoftwareCompilationsRequest.cs b/Marechai.ApiClient/Models/MergeSoftwareCompilationsRequest.cs new file mode 100644 index 00000000..0af769f5 --- /dev/null +++ b/Marechai.ApiClient/Models/MergeSoftwareCompilationsRequest.cs @@ -0,0 +1,69 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace Marechai.ApiClient.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class MergeSoftwareCompilationsRequest : IAdditionalDataHolder, IParsable + #pragma warning restore CS1591 + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// The sourceIds property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public List? SourceIds { get; set; } +#nullable restore +#else + public List SourceIds { get; set; } +#endif + /// The targetId property + public int? TargetId { get; set; } + /// + /// Instantiates a new and sets the default values. + /// + public MergeSoftwareCompilationsRequest() + { + AdditionalData = new Dictionary(); + } + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + if(ReferenceEquals(parseNode, null)) throw new ArgumentNullException(nameof(parseNode)); + return new global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest(); + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "sourceIds", n => { SourceIds = n.GetCollectionOfPrimitiveValues()?.AsList(); } }, + { "targetId", n => { TargetId = n.GetIntValue(); } }, + }; + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfPrimitiveValues("sourceIds", SourceIds); + writer.WriteIntValue("targetId", TargetId); + writer.WriteAdditionalData(AdditionalData); + } + } +} +#pragma warning restore CS0618 diff --git a/Marechai.ApiClient/SoftwareCompilations/Item/Merge/MergeRequestBuilder.cs b/Marechai.ApiClient/SoftwareCompilations/Item/Merge/MergeRequestBuilder.cs new file mode 100644 index 00000000..2dc8a207 --- /dev/null +++ b/Marechai.ApiClient/SoftwareCompilations/Item/Merge/MergeRequestBuilder.cs @@ -0,0 +1,102 @@ +// +#pragma warning disable CS0618 +using Marechai.ApiClient.Models; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace Marechai.ApiClient.SoftwareCompilations.Item.Merge +{ + /// + /// Builds and executes requests for operations under \software-compilations\{id}\merge + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class MergeRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public MergeRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software-compilations/{id}/merge", pathParameters) + { + } + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public MergeRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/software-compilations/{id}/merge", rawUrl) + { + } + /// A + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 401 status code + /// When receiving a 404 status code + /// When receiving a 409 status code +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public async Task PostAsync(global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { +#nullable restore +#else + public async Task PostAsync(global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest body, Action> requestConfiguration = default, CancellationToken cancellationToken = default) + { +#endif + if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue }, + { "401", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue }, + { "404", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue }, + { "409", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendPrimitiveAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest body, Action>? requestConfiguration = default) + { +#nullable restore +#else + public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.MergeSoftwareCompilationsRequest body, Action> requestConfiguration = default) + { +#endif + if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/json, text/plain;q=0.9"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/json", body); + return requestInfo; + } + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::Marechai.ApiClient.SoftwareCompilations.Item.Merge.MergeRequestBuilder WithUrl(string rawUrl) + { + return new global::Marechai.ApiClient.SoftwareCompilations.Item.Merge.MergeRequestBuilder(rawUrl, RequestAdapter); + } + /// + /// Configuration for the request such as headers, query parameters, and middleware options. + /// + [Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")] + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class MergeRequestBuilderPostRequestConfiguration : RequestConfiguration + { + } + } +} +#pragma warning restore CS0618 diff --git a/Marechai.ApiClient/SoftwareCompilations/Item/SoftwareCompilationsItemRequestBuilder.cs b/Marechai.ApiClient/SoftwareCompilations/Item/SoftwareCompilationsItemRequestBuilder.cs index a128593c..b2bc2e18 100644 --- a/Marechai.ApiClient/SoftwareCompilations/Item/SoftwareCompilationsItemRequestBuilder.cs +++ b/Marechai.ApiClient/SoftwareCompilations/Item/SoftwareCompilationsItemRequestBuilder.cs @@ -3,6 +3,7 @@ using Marechai.ApiClient.Models; using Marechai.ApiClient.SoftwareCompilations.Item.Compilations; using Marechai.ApiClient.SoftwareCompilations.Item.Covers; +using Marechai.ApiClient.SoftwareCompilations.Item.Merge; using Marechai.ApiClient.SoftwareCompilations.Item.Releases; using Marechai.ApiClient.SoftwareCompilations.Item.Software; using Marechai.ApiClient.SoftwareCompilations.Item.Versions; @@ -32,6 +33,11 @@ namespace Marechai.ApiClient.SoftwareCompilations.Item { get => new global::Marechai.ApiClient.SoftwareCompilations.Item.Covers.CoversRequestBuilder(PathParameters, RequestAdapter); } + /// The merge property + public global::Marechai.ApiClient.SoftwareCompilations.Item.Merge.MergeRequestBuilder Merge + { + get => new global::Marechai.ApiClient.SoftwareCompilations.Item.Merge.MergeRequestBuilder(PathParameters, RequestAdapter); + } /// The releases property public global::Marechai.ApiClient.SoftwareCompilations.Item.Releases.ReleasesRequestBuilder Releases { diff --git a/Marechai.ApiClient/kiota-lock.json b/Marechai.ApiClient/kiota-lock.json index b4bc256f..d42f148f 100644 --- a/Marechai.ApiClient/kiota-lock.json +++ b/Marechai.ApiClient/kiota-lock.json @@ -1,5 +1,5 @@ { - "descriptionHash": "EF23CFA3E3D7BEEBEDACF0F1C162F3E65C5100A4E247529FC19A317185AC443E038F5D1F706BED6C0A696F2ABF5E1C4CECB13352C16548B9812C094FC0E068CC", + "descriptionHash": "0AA7E1D641D98EEE0410A0EB52E5A5C64562D47536F6BA516F5AF89FD4902F6253992E5ABA435A45DEB050C4C933C2E34B1474F7D82962B9971E337948E133B7", "descriptionLocation": "../../../../../tmp/openapi.json", "lockFileVersion": "1.0.0", "kiotaVersion": "1.31.1", diff --git a/Marechai.App/Presentation/ViewModels/Admin/AdminSoftwareCompilationsViewModel.cs b/Marechai.App/Presentation/ViewModels/Admin/AdminSoftwareCompilationsViewModel.cs index 6a966d96..fd421d0c 100644 --- a/Marechai.App/Presentation/ViewModels/Admin/AdminSoftwareCompilationsViewModel.cs +++ b/Marechai.App/Presentation/ViewModels/Admin/AdminSoftwareCompilationsViewModel.cs @@ -27,6 +27,8 @@ public partial class AdminSoftwareCompilationsViewModel : ObservableObject, IReg private CancellationTokenSource? _predecessorPickerDebounce; private CancellationTokenSource? _includedSoftwareDebounce; private CancellationTokenSource? _includedCompilationDebounce; + private CancellationTokenSource? _mergeTargetDebounce; + private CancellationTokenSource? _mergeSourceDebounce; private int? _editingId; private List? _allMachines; @@ -91,6 +93,18 @@ public partial class AdminSoftwareCompilationsViewModel : ObservableObject, IReg [ObservableProperty] private ObservableCollection _includedCompilationSuggestions = []; [ObservableProperty] private SoftwareCompilationDto? _selectedIncludedCompilation; + // --- Merge --- + [ObservableProperty] private bool _isMerging; + [ObservableProperty] private string _mergeTargetSearchText = string.Empty; + [ObservableProperty] private ObservableCollection _mergeTargetSuggestions = []; + [ObservableProperty] private SoftwareCompilationDto? _selectedMergeTargetCompilation; + [ObservableProperty] private string _mergeSourceSearchText = string.Empty; + [ObservableProperty] private ObservableCollection _mergeSourceSuggestions = []; + [ObservableProperty] private SoftwareCompilationDto? _mergeSourcePickerValue; + [ObservableProperty] private ObservableCollection _mergeSelectedSources = []; + + public bool CanConfirmMerge => SelectedMergeTargetCompilation?.Id is not null && MergeSelectedSources.Count > 0; + public AdminSoftwareCompilationsViewModel(SoftwareCompilationsService service, IJwtService jwtService, ITokenService tokenService, @@ -128,6 +142,11 @@ public partial class AdminSoftwareCompilationsViewModel : ObservableObject, IReg AddIncludedCompilationCommand = new AsyncRelayCommand(AddIncludedCompilationAsync); RemoveIncludedCompilationCommand = new AsyncRelayCommand(RemoveIncludedCompilationAsync); + OpenMergeCommand = new RelayCommand(OpenMerge); + ConfirmMergeCommand = new AsyncRelayCommand(ConfirmMergeAsync); + CancelMergeCommand = new RelayCommand(CancelMerge); + RemoveMergeSourceCommand = new RelayCommand(RemoveMergeSource); + CheckAdminRole(); } @@ -147,6 +166,11 @@ public partial class AdminSoftwareCompilationsViewModel : ObservableObject, IReg public IAsyncRelayCommand AddIncludedCompilationCommand { get; } public IAsyncRelayCommand RemoveIncludedCompilationCommand { get; } + public IRelayCommand OpenMergeCommand { get; } + public IAsyncRelayCommand ConfirmMergeCommand { get; } + public IRelayCommand CancelMergeCommand { get; } + public IRelayCommand RemoveMergeSourceCommand { get; } + // --- IRegionAware --- public bool IsNavigationTarget(NavigationContext navigationContext) => true; @@ -617,4 +641,123 @@ public partial class AdminSoftwareCompilationsViewModel : ObservableObject, IReg } private static string? NullIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? null : value; + + // --- Merge --- + + public string MergeConfirmDialogTitle => _localizer["MergeConfirmDialogTitle"]; + public string MergeConfirmDialogMessage => _localizer["MergeConfirmDialogMessage"]; + public string MergeButtonText => _localizer["MergeButton"]; + public string CancelButtonText => _localizer["CancelButton"]; + + partial void OnSelectedMergeTargetCompilationChanged(SoftwareCompilationDto? value) + { + MergeSelectedSources.Clear(); + MergeSourceSearchText = string.Empty; + MergeSourceSuggestions.Clear(); + OnPropertyChanged(nameof(CanConfirmMerge)); + } + + partial void OnMergeSourcePickerValueChanged(SoftwareCompilationDto? value) + { + if(value?.Id is null) return; + + if(MergeSelectedSources.All(s => s.Id != value.Id)) + MergeSelectedSources.Add(value); + + MergeSourcePickerValue = null; + MergeSourceSearchText = string.Empty; + MergeSourceSuggestions.Clear(); + OnPropertyChanged(nameof(CanConfirmMerge)); + } + + private void OpenMerge() + { + CancelEdit(); + HasError = false; + ErrorMessage = string.Empty; + IsMerging = true; + MergeTargetSearchText = string.Empty; + MergeTargetSuggestions.Clear(); + SelectedMergeTargetCompilation = null; + MergeSourceSearchText = string.Empty; + MergeSourceSuggestions.Clear(); + MergeSelectedSources.Clear(); + } + + public void UpdateMergeTargetSuggestions(string query) => Debounce(ref _mergeTargetDebounce, async () => + { + List results = await _service.SearchAsync(query); + MergeTargetSuggestions.Clear(); + foreach(SoftwareCompilationDto c in results) MergeTargetSuggestions.Add(c); + }); + + public void UpdateMergeSourceSuggestions(string query) => Debounce(ref _mergeSourceDebounce, async () => + { + List results = await _service.SearchAsync(query); + + MergeSourceSuggestions.Clear(); + + foreach(SoftwareCompilationDto c in results) + if(c.Id != SelectedMergeTargetCompilation?.Id && MergeSelectedSources.All(s => s.Id != c.Id)) + MergeSourceSuggestions.Add(c); + }); + + private void RemoveMergeSource(SoftwareCompilationDto? item) + { + if(item == null) return; + + MergeSelectedSources.Remove(item); + OnPropertyChanged(nameof(CanConfirmMerge)); + } + + private async Task ConfirmMergeAsync() + { + if(SelectedMergeTargetCompilation?.Id is not int targetId) return; + + List sourceIds = MergeSelectedSources + .Where(c => c.Id is not null) + .Select(c => c.Id!.Value) + .ToList(); + + if(sourceIds.Count == 0) return; + + try + { + HasError = false; + ErrorMessage = string.Empty; + + bool succeeded = await _service.MergeAsync(targetId, sourceIds); + + if(!succeeded) + { + ErrorMessage = _localizer["FailedToMergeSoftwareCompilations"]; + HasError = true; + + return; + } + + CancelMerge(); + await LoadAsync(); + } + catch(Exception ex) + { + _logger.LogError(ex, "Error merging software compilations into {TargetId}", targetId); + ErrorMessage = _localizer["FailedToMergeSoftwareCompilations"]; + HasError = true; + } + } + + private void CancelMerge() + { + IsMerging = false; + MergeTargetSearchText = string.Empty; + MergeTargetSuggestions.Clear(); + SelectedMergeTargetCompilation = null; + MergeSourceSearchText = string.Empty; + MergeSourceSuggestions.Clear(); + MergeSelectedSources.Clear(); + HasError = false; + ErrorMessage = string.Empty; + OnPropertyChanged(nameof(CanConfirmMerge)); + } } diff --git a/Marechai.App/Presentation/Views/Admin/AdminSoftwareCompilationsPage.xaml b/Marechai.App/Presentation/Views/Admin/AdminSoftwareCompilationsPage.xaml index c931778b..404c6373 100644 --- a/Marechai.App/Presentation/Views/Admin/AdminSoftwareCompilationsPage.xaml +++ b/Marechai.App/Presentation/Views/Admin/AdminSoftwareCompilationsPage.xaml @@ -65,6 +65,8 @@ + @@ -301,6 +303,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +