Skip to content

Commit

Permalink
Improves behavior when multiple attempts are made to remove one or al…
Browse files Browse the repository at this point in the history
…l ViewModels (#31)
  • Loading branch information
michaelpduda authored May 26, 2024
2 parents 6158b04 + 8eb6733 commit bb2c53d
Show file tree
Hide file tree
Showing 15 changed files with 349 additions and 100 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ UpbeatUI implementations are available as NuGet packages:
- [![Nuget](https://img.shields.io/nuget/v/UpbeatUI.Extensions.DependencyInjection)](https://www.nuget.org/packages/UpbeatUI.Extensions.DependencyInjection/) - `UpbeatUI.Extensions.DependencyInjection`: An implementation integrated with [`Microsoft.Extensions.DependencyInjection`](https://www.nuget.org/packages/Microsoft.Extensions.DependencyInjection) (`IServiceProvider`) that provides dependency injection capabilities and automatic Parameters-ViewModel-View mapping via naming convention.
- [![Nuget](https://img.shields.io/nuget/v/UpbeatUI.Extensions.Hosting)](https://www.nuget.org/packages/UpbeatUI.Extensions.Hosting/) - `UpbeatUI.Extensions.Hosting`: An implementation integrated with [`Microsoft.Extensions.Hosting`](https://www.nuget.org/packages/Microsoft.Extensions.Hosting) (`IHostBuilder`) for easy setup and automatic teardown.

> Check the [Releases](https://github.com/Pulselyre/UpbeatUI/releases) page for pre-release/release-candidate versions with the latest bug fixes, features, and improvements.
## Examples

Three samples are included: one showing [manual setup](samples/ManualUpbeatUISample) and teardown without dependency injection, one showing manual setup and teardown with [dependency injection](samples/ServiceProvidedUpbeatUISample) using an `IServiceProvider`, and one showing [automatic setup and teardown](samples/HostedUpbeatUISample) using an `IHostBuilder`. All samples demonstrate the following capabilities:
Expand Down
18 changes: 18 additions & 0 deletions samples/HostedUpbeatUISample/ViewModel/SharedListDataViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public SharedListDataViewModel(
Strings = new ReadOnlyObservableCollection<string>(_strings);
_strings.Synchronize(_sharedList.Strings);

// Registering a CloseCallback allows the ViewModel to prevent itself from closing. For example: if there is unsaved work. This can also completely prevent the application from shutting down. CloseCallbacks can be either async or non-async methods/lambdas.
_upbeatService.RegisterCloseCallback(AskBeforeClosingAsync);

_sharedList.StringAdded += SharedListStringAdded;
}

Expand Down Expand Up @@ -61,6 +64,21 @@ await _upbeatService.OpenViewModelAsync(
public void Dispose() =>
_sharedList.StringAdded -= SharedListStringAdded;

// This CloseCallback method opens a new ViewModel and View to confirm that the user wants to close this ViewModel.
private async Task<bool> AskBeforeClosingAsync()
{
var okToClose = false;
// OpenViewModelAsync can be awaited, and will return once the child ViewModel is closed. This is useful to show a popup requesting input from the user.
await _upbeatService.OpenViewModelAsync(
new ConfirmPopupViewModel.Parameters
{
Message = "Close the shared list?\nAll added strings will be lost.",
// The ConfirmPopupViewModel will execute this callback (set the okToClose bool to true) if the user confirms that closing. If the popup closes without the user confirming, okToClose remains false, and the application will remain running.
ConfirmCallback = () => okToClose = true,
}).ConfigureAwait(true);
return okToClose;
}

private void SharedListStringAdded(object sender, EventArgs e) =>
Application.Current.Dispatcher.Invoke(() => _strings.Synchronize(_sharedList.Strings)); // Ensure that the collection is changed on the UI thread

Expand Down
18 changes: 18 additions & 0 deletions samples/ManualUpbeatUISample/ViewModel/SharedListDataViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public SharedListDataViewModel(
Strings = new ReadOnlyObservableCollection<string>(_strings);
_strings.Synchronize(_sharedList.Strings);

// Registering a CloseCallback allows the ViewModel to prevent itself from closing. For example: if there is unsaved work. This can also completely prevent the application from shutting down. CloseCallbacks can be either async or non-async methods/lambdas.
_upbeatService.RegisterCloseCallback(AskBeforeClosingAsync);

_sharedList.StringAdded += SharedListStringAdded;
}

Expand Down Expand Up @@ -61,6 +64,21 @@ await _upbeatService.OpenViewModelAsync(
public void Dispose() =>
_sharedList.StringAdded -= SharedListStringAdded;

// This CloseCallback method opens a new ViewModel and View to confirm that the user wants to close this ViewModel.
private async Task<bool> AskBeforeClosingAsync()
{
var okToClose = false;
// OpenViewModelAsync can be awaited, and will return once the child ViewModel is closed. This is useful to show a popup requesting input from the user.
await _upbeatService.OpenViewModelAsync(
new ConfirmPopupViewModel.Parameters
{
Message = "Close the shared list?\nAll added strings will be lost.",
// The ConfirmPopupViewModel will execute this callback (set the okToClose bool to true) if the user confirms that closing. If the popup closes without the user confirming, okToClose remains false, and the application will remain running.
ConfirmCallback = () => okToClose = true,
}).ConfigureAwait(true);
return okToClose;
}

private void SharedListStringAdded(object sender, EventArgs e) =>
Application.Current.Dispatcher.Invoke(() => _strings.Synchronize(_sharedList.Strings)); // Ensure that the collection is changed on the UI thread

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public SharedListDataViewModel(
Strings = new ReadOnlyObservableCollection<string>(_strings);
_strings.Synchronize(_sharedList.Strings);

// Registering a CloseCallback allows the ViewModel to prevent itself from closing. For example: if there is unsaved work. This can also completely prevent the application from shutting down. CloseCallbacks can be either async or non-async methods/lambdas.
_upbeatService.RegisterCloseCallback(AskBeforeClosingAsync);

_sharedList.StringAdded += SharedListStringAdded;
}

Expand Down Expand Up @@ -61,6 +64,21 @@ await _upbeatService.OpenViewModelAsync(
public void Dispose() =>
_sharedList.StringAdded -= SharedListStringAdded;

// This CloseCallback method opens a new ViewModel and View to confirm that the user wants to close this ViewModel.
private async Task<bool> AskBeforeClosingAsync()
{
var okToClose = false;
// OpenViewModelAsync can be awaited, and will return once the child ViewModel is closed. This is useful to show a popup requesting input from the user.
await _upbeatService.OpenViewModelAsync(
new ConfirmPopupViewModel.Parameters
{
Message = "Close the shared list?\nAll added strings will be lost.",
// The ConfirmPopupViewModel will execute this callback (set the okToClose bool to true) if the user confirms that closing. If the popup closes without the user confirming, okToClose remains false, and the application will remain running.
ConfirmCallback = () => okToClose = true,
}).ConfigureAwait(true);
return okToClose;
}

private void SharedListStringAdded(object sender, EventArgs e) =>
Application.Current.Dispatcher.Invoke(() => _strings.Synchronize(_sharedList.Strings)); // Ensure that the collection is changed on the UI thread

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
<Copyright>© Michael P. Duda 2020-2024</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<!-- Update the following properties when publishing a new Nuget version -->
<Version>2.1.0</Version>
<PackageValidationBaselineVersion>2.0.0</PackageValidationBaselineVersion>
<Version>2.2.0-rc1</Version>
<PackageValidationBaselineVersion>2.1.0</PackageValidationBaselineVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
<Copyright>© Michael P. Duda 2020-2024</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<!-- Update the following properties when publishing a new Nuget version -->
<Version>4.1.0</Version>
<PackageValidationBaselineVersion>4.0.0</PackageValidationBaselineVersion>
<Version>4.2.0-rc1</Version>
<PackageValidationBaselineVersion>4.1.0</PackageValidationBaselineVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
1 change: 1 addition & 0 deletions source/UpbeatUI.Tests/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ root = false

# CSharp code style settings:
[*.cs]
dotnet_diagnostic.CA1001.severity = silent # Types that own disposable fields should be disposable
dotnet_diagnostic.CA1707.severity = silent # Identifiers should not contain underscores
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
using NUnit.Framework;
using UpbeatUI.ViewModel.ListSynchronize;

namespace UpbeatUI.Tests.ListSynchronize_Tests
namespace UpbeatUI.Tests.ViewModel.ListSynchronize.ExtensionMethods_Tests
{
[TestFixture]
public class Values_Only
{
[Test]
Expand Down Expand Up @@ -67,6 +68,7 @@ private static void ExecuteAndTestSync(
}
}

[TestFixture]
public class With_Synchronizer
{
[Test]
Expand Down Expand Up @@ -140,6 +142,7 @@ private static void ExecuteAndTestSync(
}
}

[TestFixture]
public class With_Synchronizer_And_Blank_Creator
{
[Test]
Expand Down
200 changes: 200 additions & 0 deletions source/UpbeatUI.Tests/ViewModel/UpbeatStack_Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/* This file is part of the UpbeatUI project, which is released under MIT License.
* See LICENSE.md or visit:
* https://github.com/pulselyre/upbeatui/blob/main/LICENSE.md
*/
using System;
using System.Threading.Tasks;
using NUnit.Framework;
using UpbeatUI.ViewModel;

namespace UpbeatUI.Tests.ViewModel.UpbeatStack_Tests
{
public class BaseFixture
{
protected UpbeatStack UpbeatStack { get; set; }
protected int CloseAttemptCount { get; set; }
protected int ClosedCount { get; set; }
protected int EmptiedCount { get; set; }

[SetUp]
public void Setuip()
{
UpbeatStack = new UpbeatStack();
CloseAttemptCount = 0;
ClosedCount = 0;
EmptiedCount = 0;
UpbeatStack.ViewModelsEmptied += (_, _) => EmptiedCount++;
UpbeatStack.MapViewModel<TestViewModel.Parameters, TestViewModel>(
(service, parameters) => new TestViewModel(service, parameters));
}

[TearDown]
public void TearDown() =>
UpbeatStack.Dispose();

protected TestViewModel.Parameters BuildParameters() =>
new()
{
CloseCallback = () =>
{
CloseAttemptCount++;
return Task.FromResult(true);
}
};

protected TestViewModel.Parameters BuildParameters(TaskCompletionSource tcs) =>
new()
{
CloseCallback = async () =>
{
_ = tcs ?? throw new ArgumentNullException(nameof(tcs));
CloseAttemptCount++;
await tcs.Task.ConfigureAwait(true);
return true;
}
};

protected TestViewModel.Parameters BuildParameters(TaskCompletionSource<bool> tcs = null) =>
new()
{
CloseCallback = async () =>
{
CloseAttemptCount++;
return await tcs.Task.ConfigureAwait(true);
}
};

protected class TestViewModel
{
public TestViewModel(IUpbeatService upbeatService, Parameters parameters)
{
if (parameters?.CloseCallback != null)
{
upbeatService?.RegisterCloseCallback(parameters.CloseCallback);
}
}

public class Parameters
{

public Func<Task<bool>> CloseCallback { get; set; }
}
}
}

[TestFixture]
public class RemoveTopViewModelCommand_Tests : BaseFixture
{
[Test]
public void AllowsOnlyOneExecution()
{
var tcs = new TaskCompletionSource();
UpbeatStack.OpenViewModel(BuildParameters(tcs), () => ClosedCount++);
Assert.AreEqual(1, UpbeatStack.Count);
UpbeatStack.RemoveTopViewModelCommand.Execute(null);
Assert.AreEqual(1, UpbeatStack.Count);
Assert.AreEqual(1, CloseAttemptCount);
Assert.AreEqual(0, ClosedCount);
Assert.AreEqual(0, EmptiedCount);
UpbeatStack.RemoveTopViewModelCommand.Execute(null);
Assert.AreEqual(1, UpbeatStack.Count);
Assert.AreEqual(1, CloseAttemptCount);
Assert.AreEqual(0, ClosedCount);
Assert.AreEqual(0, EmptiedCount);
tcs.SetResult();
Assert.AreEqual(0, UpbeatStack.Count);
Assert.AreEqual(1, CloseAttemptCount);
Assert.AreEqual(1, ClosedCount);
Assert.AreEqual(1, EmptiedCount);
}

[Test]
public void CanExecute_Returns_True_When_Not_Executiong()
{
UpbeatStack.OpenViewModel(new TestViewModel.Parameters(), () => ClosedCount++);
Assert.IsTrue(UpbeatStack.RemoveTopViewModelCommand.CanExecute(null));
}

[Test]
public void CanExecute_Returns_False_When_No_ViewModels() =>
Assert.IsFalse(UpbeatStack.RemoveTopViewModelCommand.CanExecute(null));

[Test]
public void CanExecute_Returns_False_When_Executiong()
{
var tcs = new TaskCompletionSource();
UpbeatStack.OpenViewModel(BuildParameters(tcs), () => ClosedCount++);
UpbeatStack.OpenViewModel(BuildParameters(tcs), () => ClosedCount++);
UpbeatStack.RemoveTopViewModelCommand.Execute(null);
Assert.IsFalse(UpbeatStack.RemoveTopViewModelCommand.CanExecute(null));
tcs.SetResult();
Assert.IsTrue(UpbeatStack.RemoveTopViewModelCommand.CanExecute(null));
}
}

[TestFixture]
public class TryCloseAllViewModelsAsync_Tests : BaseFixture
{
[Test]
public async Task Removes_All_ViewModels_When_Allowed()
{
var tcs = new TaskCompletionSource();
UpbeatStack.OpenViewModel(BuildParameters(tcs), () => ClosedCount++);
UpbeatStack.OpenViewModel(BuildParameters(tcs), () => ClosedCount++);
Assert.AreEqual(2, UpbeatStack.Count);
tcs.SetResult();
Assert.IsTrue(await UpbeatStack.TryCloseAllViewModelsAsync().ConfigureAwait(true));
Assert.AreEqual(0, UpbeatStack.Count);
Assert.AreEqual(2, CloseAttemptCount);
Assert.AreEqual(2, ClosedCount);
Assert.AreEqual(1, EmptiedCount);
}

[Test]
public async Task Does_Not_Remove_All_ViewModels_When_Not_Allowed()
{
var tcs1 = new TaskCompletionSource<bool>();
var tcs2 = new TaskCompletionSource<bool>();
UpbeatStack.OpenViewModel(BuildParameters(tcs1), () => ClosedCount++);
UpbeatStack.OpenViewModel(BuildParameters(tcs2), () => ClosedCount++);
Assert.AreEqual(2, UpbeatStack.Count);
tcs1.SetResult(false);
tcs2.SetResult(true);
Assert.IsFalse(await UpbeatStack.TryCloseAllViewModelsAsync().ConfigureAwait(true));
Assert.AreEqual(1, UpbeatStack.Count);
Assert.AreEqual(2, CloseAttemptCount);
Assert.AreEqual(1, ClosedCount);
Assert.AreEqual(0, EmptiedCount);
}

[Test]
public async Task Waits_For_Already_Closing_ViewModel()
{
var tcs1 = new TaskCompletionSource();
var tcs2 = new TaskCompletionSource();
UpbeatStack.OpenViewModel(BuildParameters(), () => ClosedCount++);
UpbeatStack.OpenViewModel(BuildParameters(tcs1), () => ClosedCount++);
Assert.AreEqual(2, UpbeatStack.Count);
UpbeatStack.RemoveTopViewModelCommand.Execute(null);
UpbeatStack.OpenViewModel(BuildParameters(tcs2), () => ClosedCount++);
Assert.AreEqual(3, UpbeatStack.Count);
Assert.AreEqual(1, CloseAttemptCount);
Assert.AreEqual(0, ClosedCount);
var closeAllTask = UpbeatStack.TryCloseAllViewModelsAsync();
Assert.AreEqual(3, UpbeatStack.Count);
Assert.AreEqual(1, CloseAttemptCount);
Assert.AreEqual(0, ClosedCount);
tcs2.SetResult();
UpbeatStack.RemoveTopViewModelCommand.Execute(null);
Assert.AreEqual(2, UpbeatStack.Count);
Assert.AreEqual(2, CloseAttemptCount);
Assert.AreEqual(1, ClosedCount);
tcs1.SetResult();
Assert.IsTrue(await closeAllTask.ConfigureAwait(true));
Assert.AreEqual(0, UpbeatStack.Count);
Assert.AreEqual(3, CloseAttemptCount);
Assert.AreEqual(3, ClosedCount);
Assert.AreEqual(1, EmptiedCount);
}
}
}
4 changes: 2 additions & 2 deletions source/UpbeatUI/UpbeatUI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
<Copyright>© Michael P. Duda 2020-2024</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<!-- Update the following properties when publishing a new Nuget version -->
<Version>5.1.0</Version>
<PackageValidationBaselineVersion>5.0.0</PackageValidationBaselineVersion>
<Version>5.2.0-rc1</Version>
<PackageValidationBaselineVersion>5.1.0</PackageValidationBaselineVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
Loading

0 comments on commit bb2c53d

Please sign in to comment.