// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.Reflection; using System.Threading; using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Routing; using Microsoft.Extensions.Logging; using Microsoft.JSInterop; using MUnique.OpenMU.DataModel; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; using MUnique.OpenMU.Web.Shared; using MUnique.OpenMU.Web.Shared.Components; using MUnique.OpenMU.Web.Shared.Components.Modal; using MUnique.OpenMU.Web.Shared.Services; /// /// Abstract common base class for an edit page. /// public abstract class EditBase : ComponentBase, IAsyncDisposable { private object? _model; private Type? _type; private bool _isOwningContext; private IContext? _persistenceContext; private CancellationTokenSource? _disposeCts; private DataLoadingState _loadingState; private Task? _loadTask; private IDisposable? _modalDisposable; private IDisposable? _navigationLockDisposable; private enum DataLoadingState { NotLoadedYet, LoadingStarted, Loading, Loaded, NotFound, Error, Cancelled, } /// /// Gets or sets the identifier of the object which should be edited. /// [Parameter] public Guid Id { get; set; } /// /// Gets or sets the of the object which should be edited. /// [Parameter] public string TypeString { get; set; } = string.Empty; /// /// Gets or sets the persistence context provider which loads and saves the object. /// [Inject] public IPersistenceContextProvider PersistenceContextProvider { get; set; } = null!; /// /// Gets or sets the modal service. /// [Inject] public IModalService ModalService { get; set; } = null!; /// /// Gets or sets the toast service. /// [Inject] public IToastService ToastService { get; set; } = null!; /// /// Gets or sets the loading overlay service. /// [Inject] public LoadingOverlayService LoadingOverlay { get; set; } = null!; /// /// Gets or sets the configuration data source. /// [Inject] public IDataSource ConfigDataSource { get; set; } = null!; /// /// Gets or sets the navigation manager. /// [Inject] public NavigationManager NavigationManager { get; set; } = null!; /// /// Gets or sets the navigation history. /// [Inject] public NavigationHistory NavigationHistory { get; set; } = null!; /// /// Gets or sets the java script runtime. /// [Inject] public IJSRuntime JavaScript { get; set; } = null!; /// /// Gets or sets the logger. /// [Inject] public ILogger? Logger { get; set; } /// /// Gets the data source of the type which is edited. /// protected virtual IDataSource EditDataSource => this.ConfigDataSource; /// /// Gets the model which should be edited. /// protected object? Model => this._model; /// /// Gets the type. /// protected virtual Type? Type => this._type ??= this.DetermineTypeByTypeString(); /// public async ValueTask DisposeAsync() { this._navigationLockDisposable?.Dispose(); this._navigationLockDisposable = null; await (this._disposeCts?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false); this._disposeCts?.Dispose(); this._disposeCts = null; await (this._loadTask ?? Task.CompletedTask).ConfigureAwait(false); await this.EditDataSource.DiscardChangesAsync().ConfigureAwait(true); if (this._isOwningContext) { this._persistenceContext?.Dispose(); } this._persistenceContext = null; } /// public override async Task SetParametersAsync(ParameterView parameters) { this._model = null; await (this._disposeCts?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false); this._disposeCts?.Dispose(); await (this._loadTask ?? Task.CompletedTask).ConfigureAwait(true); await base.SetParametersAsync(parameters).ConfigureAwait(true); } /// protected override async Task OnParametersSetAsync() { this._loadingState = DataLoadingState.LoadingStarted; var cts = new CancellationTokenSource(); this._disposeCts = cts; this._type = null; this._loadTask = Task.Run(() => this.LoadDataAsync(cts.Token), cts.Token); await base.OnParametersSetAsync().ConfigureAwait(true); } /// protected override void BuildRenderTree(RenderTreeBuilder builder) { if (this.Model is null) { return; } builder.OpenComponent(0); builder.AddAttribute(1, nameof(Breadcrumb.Caption), this.Model.GetName()); builder.CloseComponent(); var downloadMarkup = this.GetDownloadMarkup(); var editorsMarkup = this.GetEditorsMarkup(); builder.AddMarkupContent(10, $"

{Resources.Edit} {this.Type!.GetTypeCaption()}

{downloadMarkup}{editorsMarkup}\r\n"); builder.OpenComponent>(11); builder.AddAttribute(12, nameof(CascadingValue.Value), this._persistenceContext); builder.AddAttribute(13, nameof(CascadingValue.IsFixed), this._isOwningContext); RenderFragment childContent = builder2 => { var sequence = 14; this.AddFormToRenderTree(builder2, ref sequence); }; builder.AddAttribute(14, nameof(CascadingValue.ChildContent), childContent); builder.CloseComponent(); } /// protected override Task OnInitializedAsync() { this._navigationLockDisposable = this.NavigationManager.RegisterLocationChangingHandler(this.OnBeforeInternalNavigationAsync); return base.OnInitializedAsync(); } /// /// Adds the form to the render tree. /// /// The builder. /// The current sequence. protected abstract void AddFormToRenderTree(RenderTreeBuilder builder, ref int currentSequence); /// protected override async Task OnAfterRenderAsync(bool firstRender) { if (this._loadingState is not DataLoadingState.Loading && this._modalDisposable is { } modal) { modal.Dispose(); this._modalDisposable = null; } if (this._loadingState == DataLoadingState.LoadingStarted) { this._loadingState = DataLoadingState.Loading; await this.InvokeAsync(() => { if (this._loadingState != DataLoadingState.Loaded) { this._modalDisposable = this.LoadingOverlay.ShowLoadingIndicator(); this.StateHasChanged(); } }).ConfigureAwait(false); } await base.OnAfterRenderAsync(firstRender).ConfigureAwait(true); } /// /// Saves the changes. /// protected async Task SaveChangesAsync() { try { if (this._persistenceContext is { } context) { var success = await context.SaveChangesAsync().ConfigureAwait(true); var text = success ? Resources.SavedChanges : Resources.NoChangesToSave; this.ToastService.ShowSuccess(text); } else { this.ToastService.ShowError(Resources.FailedByUninitializedContext); } } catch (Exception ex) { this.Logger?.LogError(ex, $"Error during saving {this.Id}"); var text = string.Format(Resources.UnexpectedErrorOccurred, ex.Message); this.ToastService.ShowError(text); } } /// /// Refreshes the data by discarding changes and reloading it from the database. /// protected async Task RefreshAsync() { var isConfirmed = await this.JavaScript.InvokeAsync("window.confirm", Resources.UnsavedChangesQuestion).ConfigureAwait(true); if (!isConfirmed) { return; } await this.EditDataSource.ForceDiscardChangesAsync().ConfigureAwait(true); this._loadingState = DataLoadingState.LoadingStarted; var cts = new CancellationTokenSource(); this._disposeCts = cts; this._loadTask = Task.Run(() => this.LoadDataAsync(cts.Token), cts.Token); this.StateHasChanged(); } /// /// Gets the optional editors markup for the current type. /// /// The optional editors markup for the current type. protected virtual string? GetEditorsMarkup() { return null; } /// /// It loads the owner of the . /// /// The cancellation token. protected virtual async ValueTask LoadOwnerAsync(CancellationToken cancellationToken) { await this.EditDataSource.GetOwnerAsync(Guid.Empty, cancellationToken).ConfigureAwait(true); } private async ValueTask OnBeforeInternalNavigationAsync(LocationChangingContext context) { if (this._persistenceContext?.HasChanges is true) { var isConfirmed = await this.JavaScript.InvokeAsync( "window.confirm", Resources.UnsavedChangesQuestion) .ConfigureAwait(true); if (!isConfirmed) { context.PreventNavigation(); } else if (this._isOwningContext) { this._persistenceContext.Dispose(); this._persistenceContext = null; } else { await this.EditDataSource.DiscardChangesAsync().ConfigureAwait(true); } } } private string? GetDownloadMarkup() { if (this.Type is not null && GenericControllerFeatureProvider.SupportedTypes.Any(t => t.Item1 == this.Type)) { var uri = $"/download/{this.Type.Name}/{this.Type.Name}_{this.Id}.json"; return $"

{Resources.DownloadAsJson}:

"; } return null; } private Type? DetermineTypeByTypeString() { return AppDomain.CurrentDomain.GetAssemblies().Where(assembly => assembly.FullName?.StartsWith(nameof(MUnique)) ?? false) .Select(assembly => assembly.GetType(this.TypeString)).FirstOrDefault(t => t != null); } private async Task LoadDataAsync(CancellationToken cancellationToken) { try { cancellationToken.ThrowIfCancellationRequested(); if (this.Type is null) { throw new InvalidOperationException($"Only types of namespace {nameof(MUnique)} can be edited on this page."); } await this.LoadOwnerAsync(cancellationToken).ConfigureAwait(true); cancellationToken.ThrowIfCancellationRequested(); if (this.EditDataSource.IsSupporting(this.Type)) { this._isOwningContext = false; this._persistenceContext = await this.EditDataSource.GetContextAsync(cancellationToken).ConfigureAwait(true); } else { this._isOwningContext = true; var gameConfiguration = await this.ConfigDataSource.GetOwnerAsync(Guid.Empty, cancellationToken).ConfigureAwait(true); this._persistenceContext = this.PersistenceContextProvider.CreateNewTypedContext(this.Type, true, gameConfiguration); } cancellationToken.ThrowIfCancellationRequested(); try { if (this.EditDataSource.IsSupporting(this.Type)) { this._model = this.Id == default ? this.EditDataSource.GetAll(this.Type).OfType().FirstOrDefault() : this.EditDataSource.Get(this.Id); } else { this._model = this.Id == default ? (await this._persistenceContext.GetAsync(this.Type, cancellationToken).ConfigureAwait(true)).OfType().FirstOrDefault() : await this._persistenceContext.GetByIdAsync(this.Id, this.Type, cancellationToken).ConfigureAwait(true); } this._loadingState = this.Model is not null ? DataLoadingState.Loaded : DataLoadingState.NotFound; } catch (OperationCanceledException) { this._loadingState = DataLoadingState.Cancelled; throw; } catch (Exception ex) { this._loadingState = DataLoadingState.Error; this.Logger?.LogError(ex, $"Could not load {this.Type.FullName} with {this.Id}: {ex.Message}{Environment.NewLine}{ex.StackTrace}"); await this.InvokeAsync(() => this.ModalService.ShowMessageAsync(Resources.Error, Resources.LoadingErrorCheckLog)).ConfigureAwait(false); } cancellationToken.ThrowIfCancellationRequested(); await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(true); } catch (OperationCanceledException) { // expected when the page is getting disposed. } catch (TargetInvocationException ex) when (ex.InnerException is ObjectDisposedException) { // See ObjectDisposedException. } catch (ObjectDisposedException) { // Happens when the user navigated away (shouldn't happen with the modal loading indicator, but we check it anyway). // It would be great to have an async api with cancellation token support in the persistence layer // For the moment, we swallow the exception } catch (Exception ex) { this.Logger?.LogError(ex, "Unexpected error when loading data."); } } }