baseline: OpenMU upstream b5a0961 (fresh source)
This commit is contained in:
435
src/Web/AdminPanel/Pages/EditBase.cs
Normal file
435
src/Web/AdminPanel/Pages/EditBase.cs
Normal file
@@ -0,0 +1,435 @@
|
||||
// <copyright file="EditBase.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Abstract common base class for an edit page.
|
||||
/// </summary>
|
||||
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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the object which should be edited.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the <see cref="Type.FullName"/> of the object which should be edited.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string TypeString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the persistence context provider which loads and saves the object.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public IPersistenceContextProvider PersistenceContextProvider { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the modal service.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public IModalService ModalService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the toast service.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public IToastService ToastService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the loading overlay service.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public LoadingOverlayService LoadingOverlay { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the configuration data source.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public IDataSource<GameConfiguration> ConfigDataSource { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the navigation manager.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public NavigationManager NavigationManager { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the navigation history.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public NavigationHistory NavigationHistory { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the java script runtime.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public IJSRuntime JavaScript { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the logger.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
public ILogger<EditBase>? Logger { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the data source of the type which is edited.
|
||||
/// </summary>
|
||||
protected virtual IDataSource EditDataSource => this.ConfigDataSource;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the model which should be edited.
|
||||
/// </summary>
|
||||
protected object? Model => this._model;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type.
|
||||
/// </summary>
|
||||
protected virtual Type? Type => this._type ??= this.DetermineTypeByTypeString();
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void BuildRenderTree(RenderTreeBuilder builder)
|
||||
{
|
||||
if (this.Model is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
builder.OpenComponent<Breadcrumb>(0);
|
||||
builder.AddAttribute(1, nameof(Breadcrumb.Caption), this.Model.GetName());
|
||||
builder.CloseComponent();
|
||||
|
||||
var downloadMarkup = this.GetDownloadMarkup();
|
||||
var editorsMarkup = this.GetEditorsMarkup();
|
||||
|
||||
builder.AddMarkupContent(10, $"<h1>{Resources.Edit} {this.Type!.GetTypeCaption()}</h1>{downloadMarkup}{editorsMarkup}\r\n");
|
||||
|
||||
builder.OpenComponent<CascadingValue<IContext>>(11);
|
||||
builder.AddAttribute(12, nameof(CascadingValue<IContext>.Value), this._persistenceContext);
|
||||
builder.AddAttribute(13, nameof(CascadingValue<IContext>.IsFixed), this._isOwningContext);
|
||||
RenderFragment childContent = builder2 =>
|
||||
{
|
||||
var sequence = 14;
|
||||
this.AddFormToRenderTree(builder2, ref sequence);
|
||||
};
|
||||
|
||||
builder.AddAttribute(14, nameof(CascadingValue<IContext>.ChildContent), childContent);
|
||||
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
this._navigationLockDisposable = this.NavigationManager.RegisterLocationChangingHandler(this.OnBeforeInternalNavigationAsync);
|
||||
return base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the form to the render tree.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder.</param>
|
||||
/// <param name="currentSequence">The current sequence.</param>
|
||||
protected abstract void AddFormToRenderTree(RenderTreeBuilder builder, ref int currentSequence);
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the changes.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the data by discarding changes and reloading it from the database.
|
||||
/// </summary>
|
||||
protected async Task RefreshAsync()
|
||||
{
|
||||
var isConfirmed = await this.JavaScript.InvokeAsync<bool>("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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the optional editors markup for the current type.
|
||||
/// </summary>
|
||||
/// <returns>The optional editors markup for the current type.</returns>
|
||||
protected virtual string? GetEditorsMarkup()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// It loads the owner of the <see cref="EditDataSource" />.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
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<bool>(
|
||||
"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 $"<p>{Resources.DownloadAsJson}: <a href=\"{uri}\" download><span class=\"oi oi-data-transfer-download\"></span></a></p>";
|
||||
}
|
||||
|
||||
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<object>().FirstOrDefault()
|
||||
: this.EditDataSource.Get(this.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._model = this.Id == default
|
||||
? (await this._persistenceContext.GetAsync(this.Type, cancellationToken).ConfigureAwait(true)).OfType<object>().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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user