diff --git a/PanoramicData.Blazor.Test/Components/PDCardDeckTests.ConcurrentDrop.cs b/PanoramicData.Blazor.Test/Components/PDCardDeckTests.ConcurrentDrop.cs
new file mode 100644
index 000000000..0a49bab29
--- /dev/null
+++ b/PanoramicData.Blazor.Test/Components/PDCardDeckTests.ConcurrentDrop.cs
@@ -0,0 +1,70 @@
+using AwesomeAssertions;
+using Bunit;
+using Microsoft.AspNetCore.Components.Web;
+using PanoramicData.Blazor.Services;
+
+namespace PanoramicData.Blazor.Test.Components;
+
+///
+/// Tests that a drop arriving while cards are still migrating between decks leaves each card in one place.
+///
+public partial class PDCardDeckTests
+{
+ private const string AnimationModulePath = "./_content/PanoramicData.Blazor/PDAnimation.razor.js";
+
+ private IRenderedComponent> RenderAnimatedGroup(Board board)
+ {
+ var group = Render>(parameters => parameters
+ .Add(p => p.DataProvider, new ListDataProviderService())
+ .Add(p => p.Transformation, (_, source, destination, cards) => board.Transform(source, destination, cards))
+ .AddChildContent>(deck => deck
+ .Add(p => p.Id, "left")
+ .Add(p => p.IsAnimated, true)
+ .Add(p => p.DataFunction, board.DataFor("left")))
+ .AddChildContent>(deck => deck
+ .Add(p => p.Id, "right")
+ .Add(p => p.IsAnimated, true)
+ .Add(p => p.DataFunction, board.DataFor("right"))));
+ group.WaitForAssertion(() => group.FindAll("div.card").Should().HaveCount(3));
+ return group;
+ }
+
+ ///
+ /// Verifies that the drop reaching every deck at once, as each deck listens for it on the document, transforms once.
+ ///
+ [Fact]
+ public async Task Group_DropReachingEveryDeck_TransformsOnce()
+ {
+ var board = new Board(transformDuration: TimeSpan.FromMilliseconds(100));
+ var group = RenderGroup(board);
+ await group.InvokeAsync(() => group.FindAll("#left div.card")[0].DragStartAsync(new DragEventArgs()));
+ await group.InvokeAsync(() => group.Find("#right").DragEnterAsync(new DragEventArgs()));
+
+ await group.InvokeAsync(() => Task.WhenAll(
+ Deck(group, "left").Instance.InitiateTransformAsync(),
+ Deck(group, "right").Instance.InitiateTransformAsync()));
+
+ board.Transforms.Should().ContainSingle();
+ group.WaitForAssertion(() => Names(group, "right").Should().Equal("A", "C"));
+ }
+
+ ///
+ /// Verifies that a drop arriving while the cards are still leaving the source deck does not put them in the destination twice.
+ ///
+ [Fact]
+ public async Task Group_DropDuringTheMigration_PutsTheCardInTheDestinationOnce()
+ {
+ var board = new Board(movesTheDroppedCards: true);
+ var cancelAnimation = JSInterop.SetupModule(AnimationModulePath).SetupVoid("cancelAnimation", _ => true);
+ var group = RenderAnimatedGroup(board);
+ await group.InvokeAsync(() => group.FindAll("#left div.card")[0].DragStartAsync(new DragEventArgs()));
+
+ var entering = group.InvokeAsync(() => group.Find("#right").DragEnterAsync(new DragEventArgs()));
+ var dropping = group.InvokeAsync(Deck(group, "right").Instance.InitiateTransformAsync);
+ cancelAnimation.SetVoidResult();
+ await Task.WhenAll(entering, dropping);
+
+ group.WaitForAssertion(() => Names(group, "right").Should().Equal("A", "C"));
+ Names(group, "left").Should().Equal("B");
+ }
+}
diff --git a/PanoramicData.Blazor.Test/Components/PDCardDeckTests.Group.cs b/PanoramicData.Blazor.Test/Components/PDCardDeckTests.Group.cs
index 6c0610090..ba34de33b 100644
--- a/PanoramicData.Blazor.Test/Components/PDCardDeckTests.Group.cs
+++ b/PanoramicData.Blazor.Test/Components/PDCardDeckTests.Group.cs
@@ -12,7 +12,7 @@ namespace PanoramicData.Blazor.Test.Components;
///
public partial class PDCardDeckTests
{
- private sealed class Board
+ private sealed class Board(TimeSpan transformDuration = default, bool movesTheDroppedCards = false)
{
public Dictionary> Decks { get; } = new()
{
@@ -25,12 +25,30 @@ private sealed class Board
public Func>> DataFor(string deckId)
=> () => Task.FromResult(new DataResponse([.. Decks[deckId]], Decks[deckId].Count));
- public Task Transform(PDCardDeck source, PDCardDeck destination, List cards)
+ private readonly TimeSpan _transformDuration = transformDuration;
+
+ /// Moves the dropped cards in the data store, as a board backed by a real data source does, rather than copying what the decks show.
+ private readonly bool _movesTheDroppedCards = movesTheDroppedCards;
+
+ public async Task Transform(PDCardDeck source, PDCardDeck destination, List cards)
{
Transforms.Add((source.Id, destination.Id, [.. cards.Select(c => c.Name)]));
- Decks[source.Id] = [.. source.Cards];
- Decks[destination.Id] = [.. destination.Cards];
- return Task.CompletedTask;
+ if (_movesTheDroppedCards)
+ {
+ Decks[source.Id].RemoveAll(cards.Contains);
+ Decks[destination.Id].InsertRange(0, cards);
+ }
+ else
+ {
+ Decks[source.Id] = [.. source.Cards];
+ Decks[destination.Id] = [.. destination.Cards];
+ }
+
+ if (_transformDuration > TimeSpan.Zero)
+ {
+ await Task.Delay(_transformDuration);
+ }
+
}
}
diff --git a/PanoramicData.Blazor/PDCardDeckGroup.razor.cs b/PanoramicData.Blazor/PDCardDeckGroup.razor.cs
index f40eed077..b8554ae8f 100644
--- a/PanoramicData.Blazor/PDCardDeckGroup.razor.cs
+++ b/PanoramicData.Blazor/PDCardDeckGroup.razor.cs
@@ -9,6 +9,12 @@ namespace PanoramicData.Blazor;
/// The type of card data managed by the decks in this group. Must implement .
public partial class PDCardDeckGroup : IDisposable where TCard : ICard
{
+ ///
+ /// Serialises card migration, drop and end of drag: each awaits the browser part way through, and a drop
+ /// that arrives in that gap would otherwise run against a half-migrated group.
+ ///
+ private readonly SemaphoreSlim _dragLock = new(1, 1);
+
///
/// Holds a list of destination deck IDs that are part of the current card migration process (this is for UI only).
///
@@ -166,6 +172,19 @@ public void RegisterDestination(PDCardDeck deck)
///
///
internal async Task UpdateGraphicalCardLocationsAsync()
+ {
+ await _dragLock.WaitAsync();
+ try
+ {
+ await MigrateCardsAsync();
+ }
+ finally
+ {
+ _ = _dragLock.Release();
+ }
+ }
+
+ private async Task MigrateCardsAsync()
{
// Must have two destinations to perform a migration.
if (_destinations.Count < 2)
@@ -208,9 +227,20 @@ internal async Task UpdateGraphicalCardLocationsAsync()
internal async Task InitiateTransformAsync()
{
- await Task.CompletedTask;
+ await _dragLock.WaitAsync();
+ try
+ {
+ await TransformCardsAsync();
+ }
+ finally
+ {
+ _ = _dragLock.Release();
+ }
+ }
- // Refresh if a transform has occurred
+ private async Task TransformCardsAsync()
+ {
+ // Every deck listens for the drop on the document, so one drop arrives once per deck; later ones find the drag over.
if (Transformation is null || _destinations.Count == 0)
{
return;
@@ -235,7 +265,7 @@ internal async Task InitiateTransformAsync()
await Transformation(_dataProviderService, _sourceDeck, destination, movingCards);
// Refresh the source deck and destination deck
- await EndDragOperationAsync();
+ await RefreshDecksAsync();
}
///
@@ -300,6 +330,19 @@ internal void SetSourceDeck(PDCardDeck source)
}
internal async Task EndDragOperationAsync()
+ {
+ await _dragLock.WaitAsync();
+ try
+ {
+ await RefreshDecksAsync();
+ }
+ finally
+ {
+ _ = _dragLock.Release();
+ }
+ }
+
+ private async Task RefreshDecksAsync()
{
var destination = _decks.FirstOrDefault(d => _destinations.Count > 0 && d.Id == _destinations.Last());
@@ -323,6 +366,7 @@ internal async Task EndDragOperationAsync()
public void Dispose()
{
LoadingIcon?.Dispose();
+ _dragLock.Dispose();
GC.SuppressFinalize(this);
}
}
\ No newline at end of file