feat(ui): port retail dialog factory lifecycle
Replace the single mutable confirmation service with retail's property-backed DialogFactory model: fresh DAT roots, context ids, queue groups, priority preemption, callback/close-notice ordering, and context cancellation. Route /die, server confirmation aborts, and guarded item use through focused semantic owners. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
43f7c7807c
commit
66bdae7a83
22 changed files with 1442 additions and 220 deletions
|
|
@ -0,0 +1,58 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.Tests.UI.Layout;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class GameplayConfirmationControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void SkillRequestAppendsContinueAndCloseNoticeSendsServerTuple()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
ImportedLayout? shown = null;
|
||||
var factory = new RetailDialogFactory(root, _ =>
|
||||
shown = FixtureLoader.LoadConfirmationDialog());
|
||||
var responses = new List<(uint Type, uint Context, bool Accepted)>();
|
||||
using var controller = new GameplayConfirmationController(
|
||||
factory,
|
||||
(type, context, accepted) => responses.Add((type, context, accepted)));
|
||||
|
||||
Assert.True(controller.HandleRequest(
|
||||
new GameEvents.CharacterConfirmationRequest(2u, 42u, "Raise this skill?")));
|
||||
Assert.Equal(
|
||||
"Raise this skill? Continue?",
|
||||
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
|
||||
|
||||
Assert.IsType<UiButton>(shown.FindElement(
|
||||
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
||||
|
||||
Assert.Equal([(2u, 42u, true)], responses);
|
||||
Assert.Equal(0u, controller.ActiveDialogContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchingConfirmationDoneClosesDialogAndUnmatchedTupleDoesNothing()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var factory = new RetailDialogFactory(root, _ =>
|
||||
FixtureLoader.LoadConfirmationDialog());
|
||||
var responses = new List<(uint Type, uint Context, bool Accepted)>();
|
||||
using var controller = new GameplayConfirmationController(
|
||||
factory,
|
||||
(type, context, accepted) => responses.Add((type, context, accepted)));
|
||||
controller.HandleRequest(new GameEvents.CharacterConfirmationRequest(7u, 99u, "Proceed?"));
|
||||
|
||||
Assert.False(controller.HandleDone(
|
||||
new GameEvents.CharacterConfirmationDone(7u, 100u)));
|
||||
Assert.True(factory.IsOpen);
|
||||
Assert.True(controller.HandleDone(
|
||||
new GameEvents.CharacterConfirmationDone(7u, 99u)));
|
||||
|
||||
Assert.False(factory.IsOpen);
|
||||
Assert.Equal([(7u, 99u, false)], responses);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RetailConfirmationDialogServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void FixtureBuildsProductionConfirmationWidgets()
|
||||
{
|
||||
var layout = FixtureLoader.LoadConfirmationDialog();
|
||||
|
||||
Assert.IsType<UiDialogRoot>(layout.Root);
|
||||
Assert.IsType<UiText>(layout.FindElement(RetailConfirmationDialogService.MessageElementId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogService.AcceptButtonId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogService.RejectButtonId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowCentersModalAndAcceptCompletesIt()
|
||||
{
|
||||
var root = new UiRoot { Width = 1024f, Height = 768f };
|
||||
var layout = FixtureLoader.LoadConfirmationDialog();
|
||||
var service = new RetailConfirmationDialogService(root, layout);
|
||||
bool? result = null;
|
||||
|
||||
service.Show("Do you really want to kill your character?", accepted => result = accepted);
|
||||
|
||||
Assert.Same(layout.Root, root.Modal);
|
||||
var popup = Assert.IsAssignableFrom<UiElement>(
|
||||
layout.FindElement(RetailConfirmationDialogService.PopupElementId));
|
||||
Assert.Equal(MathF.Round((1024f - popup.Width) * 0.5f), popup.Left);
|
||||
Assert.Equal(MathF.Round((768f - popup.Height) * 0.5f), popup.Top);
|
||||
|
||||
var accept = Assert.IsType<UiButton>(
|
||||
layout.FindElement(RetailConfirmationDialogService.AcceptButtonId));
|
||||
accept.OnClick!();
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Null(root.Modal);
|
||||
Assert.False(service.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestsArePresentedInFifoOrder()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layout = FixtureLoader.LoadConfirmationDialog();
|
||||
var service = new RetailConfirmationDialogService(root, layout);
|
||||
var results = new List<string>();
|
||||
|
||||
service.Show("first", accepted => results.Add($"first:{accepted}"));
|
||||
service.Show("second", accepted => results.Add($"second:{accepted}"));
|
||||
|
||||
Assert.Equal(1, service.PendingCount);
|
||||
Assert.IsType<UiButton>(layout.FindElement(
|
||||
RetailConfirmationDialogService.RejectButtonId)).OnClick!();
|
||||
Assert.Equal(["first:False"], results);
|
||||
Assert.True(service.IsOpen);
|
||||
Assert.Equal(0, service.PendingCount);
|
||||
|
||||
Assert.IsType<UiButton>(layout.FindElement(
|
||||
RetailConfirmationDialogService.AcceptButtonId)).OnClick!();
|
||||
Assert.Equal(["first:False", "second:True"], results);
|
||||
Assert.False(service.IsOpen);
|
||||
}
|
||||
}
|
||||
213
tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs
Normal file
213
tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class RetailDialogFactoryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(RetailDialogType.Confirmation, 0x15u)]
|
||||
[InlineData(RetailDialogType.Wait, 0x31u)]
|
||||
[InlineData(RetailDialogType.Message, 0x24u)]
|
||||
[InlineData(RetailDialogType.TextInput, 0x28u)]
|
||||
[InlineData(RetailDialogType.ConfirmationTextInput, 0x2Cu)]
|
||||
[InlineData(RetailDialogType.Menu, 0x1Bu)]
|
||||
[InlineData(RetailDialogType.ConfirmationMenu, 0x1Fu)]
|
||||
public void DialogTypesMapToRetailCatalogRoots(
|
||||
RetailDialogType type,
|
||||
uint expectedRoot)
|
||||
=> Assert.Equal(expectedRoot, RetailDialogFactory.RootElementId(type));
|
||||
|
||||
[Fact]
|
||||
public void FixtureBuildsProductionConfirmationWidgets()
|
||||
{
|
||||
var layout = FixtureLoader.LoadConfirmationDialog();
|
||||
|
||||
Assert.IsType<UiDialogRoot>(layout.Root);
|
||||
Assert.IsType<UiText>(layout.FindElement(RetailConfirmationDialogView.MessageElementId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogView.AcceptButtonId));
|
||||
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogView.RejectButtonId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfirmationCreatesFreshCenteredRootAndReturnsPropertyResult()
|
||||
{
|
||||
var root = new UiRoot { Width = 1024f, Height = 768f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
var factory = CreateFactory(root, layouts);
|
||||
bool? result = null;
|
||||
|
||||
factory.MakeConfirmation(
|
||||
"Do you really want to kill your character?",
|
||||
data => result = data.GetBoolean(RetailDialogProperty.ConfirmationResult));
|
||||
|
||||
ImportedLayout layout = Assert.Single(layouts);
|
||||
Assert.Same(layout.Root, root.Modal);
|
||||
var popup = Assert.IsAssignableFrom<UiElement>(
|
||||
layout.FindElement(RetailConfirmationDialogView.PopupElementId));
|
||||
Assert.Equal(MathF.Round((1024f - popup.Width) * 0.5f), popup.Left);
|
||||
Assert.Equal(MathF.Round((768f - popup.Height) * 0.5f), popup.Top);
|
||||
|
||||
Accept(layout);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Null(root.Modal);
|
||||
Assert.False(factory.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameQueuePresentsFifoUsingFreshLiveRoots()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
var factory = CreateFactory(root, layouts);
|
||||
var results = new List<string>();
|
||||
|
||||
factory.MakeConfirmation("first", data =>
|
||||
results.Add($"first:{data.GetBoolean(RetailDialogProperty.ConfirmationResult)}"));
|
||||
factory.MakeConfirmation("second", data =>
|
||||
results.Add($"second:{data.GetBoolean(RetailDialogProperty.ConfirmationResult)}"));
|
||||
|
||||
Assert.Equal(1, factory.PendingCount);
|
||||
Reject(layouts[0]);
|
||||
Assert.Equal(["first:False"], results);
|
||||
Assert.Equal(2, layouts.Count);
|
||||
Assert.NotSame(layouts[0].Root, layouts[1].Root);
|
||||
Assert.Same(layouts[1].Root, root.Modal);
|
||||
Assert.Equal("second", Message(layouts[1]));
|
||||
|
||||
Accept(layouts[1]);
|
||||
Assert.Equal(["first:False", "second:True"], results);
|
||||
Assert.False(factory.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueueGroupsAndNonQueuedDialogsCanBeActiveTogether()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
var factory = CreateFactory(root, layouts);
|
||||
|
||||
uint first = factory.MakeConfirmation("queue two", queueKey: 2u);
|
||||
uint second = factory.MakeConfirmation("queue three", queueKey: 3u);
|
||||
uint third = factory.MakeConfirmation("nonqueued", queueKey: 1u);
|
||||
|
||||
Assert.Equal(3, factory.ActiveCount);
|
||||
Assert.Equal(0, factory.PendingCount);
|
||||
Assert.Same(layouts[2].Root, root.Modal);
|
||||
|
||||
Assert.True(factory.CloseDialog(third));
|
||||
Assert.Same(layouts[1].Root, root.Modal);
|
||||
Assert.True(factory.CloseDialog(second));
|
||||
Assert.Same(layouts[0].Root, root.Modal);
|
||||
Assert.True(factory.CloseDialog(first));
|
||||
Assert.Null(root.Modal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriorityDialogSuspendsCurrentAndRestoresItBeforeOlderPendingWork()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
var factory = CreateFactory(root, layouts);
|
||||
|
||||
factory.MakeConfirmation("current");
|
||||
factory.MakeConfirmation("ordinary pending");
|
||||
factory.MakeConfirmation("priority", priority: true);
|
||||
|
||||
Assert.Equal(2, factory.PendingCount);
|
||||
Assert.Equal("priority", Message(layouts[1]));
|
||||
Assert.DoesNotContain(layouts[0].Root, root.Children);
|
||||
|
||||
Reject(layouts[1]);
|
||||
|
||||
Assert.Equal(3, layouts.Count);
|
||||
Assert.Equal("current", Message(layouts[2]));
|
||||
Assert.Equal(1, factory.PendingCount);
|
||||
Assert.NotSame(layouts[0].Root, layouts[2].Root);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CallbackPrecedesCloseNoticeAndCustomLabelsAreApplied()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
var factory = CreateFactory(root, layouts);
|
||||
var order = new List<string>();
|
||||
factory.DialogClosed += (_, _) => order.Add("notice");
|
||||
RetailDialogData data = RetailDialogData.Confirmation("Proceed?")
|
||||
.Set(RetailDialogProperty.AcceptLabel, "Yes")
|
||||
.Set(RetailDialogProperty.RejectLabel, "No");
|
||||
|
||||
factory.MakeDialog(data, _ => order.Add("callback"));
|
||||
|
||||
Assert.Equal("Yes", Button(layouts[0], RetailConfirmationDialogView.AcceptButtonId).Label);
|
||||
Assert.Equal("No", Button(layouts[0], RetailConfirmationDialogView.RejectButtonId).Label);
|
||||
Accept(layouts[0]);
|
||||
Assert.Equal(["callback", "notice"], order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingContextCanBeClosedWithoutDisturbingActiveDialog()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
var factory = CreateFactory(root, layouts);
|
||||
var completed = new List<uint>();
|
||||
|
||||
uint active = factory.MakeConfirmation("active");
|
||||
uint pending = 0u;
|
||||
pending = factory.MakeConfirmation("pending", _ => completed.Add(pending));
|
||||
|
||||
Assert.True(factory.CloseDialog(pending));
|
||||
Assert.Equal([pending], completed);
|
||||
Assert.Equal(0, factory.PendingCount);
|
||||
Assert.Same(layouts[0].Root, root.Modal);
|
||||
Assert.True(factory.CloseDialog(active));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetCompletesActiveAndPendingContexts()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
var layouts = new List<ImportedLayout>();
|
||||
var factory = CreateFactory(root, layouts);
|
||||
var completed = new List<string>();
|
||||
var notices = new List<uint>();
|
||||
factory.DialogClosed += (context, _) => notices.Add(context);
|
||||
|
||||
uint active = factory.MakeConfirmation("active", _ => completed.Add("active"));
|
||||
uint pending = factory.MakeConfirmation("pending", _ => completed.Add("pending"));
|
||||
factory.Reset();
|
||||
|
||||
Assert.Equal(["active", "pending"], completed);
|
||||
Assert.Equal([active, pending], notices);
|
||||
Assert.False(factory.IsOpen);
|
||||
Assert.Equal(0, factory.PendingCount);
|
||||
Assert.Null(root.Modal);
|
||||
}
|
||||
|
||||
private static RetailDialogFactory CreateFactory(
|
||||
UiRoot root,
|
||||
List<ImportedLayout> layouts)
|
||||
=> new(root, type =>
|
||||
{
|
||||
Assert.Equal(RetailDialogType.Confirmation, type);
|
||||
ImportedLayout layout = FixtureLoader.LoadConfirmationDialog();
|
||||
layouts.Add(layout);
|
||||
return layout;
|
||||
});
|
||||
|
||||
private static UiButton Button(ImportedLayout layout, uint id)
|
||||
=> Assert.IsType<UiButton>(layout.FindElement(id));
|
||||
|
||||
private static void Accept(ImportedLayout layout)
|
||||
=> Button(layout, RetailConfirmationDialogView.AcceptButtonId).OnClick!();
|
||||
|
||||
private static void Reject(ImportedLayout layout)
|
||||
=> Button(layout, RetailConfirmationDialogView.RejectButtonId).OnClick!();
|
||||
|
||||
private static string Message(ImportedLayout layout)
|
||||
=> string.Join(" ", Assert.IsType<UiText>(layout.FindElement(
|
||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
var dialogs = LayoutImporter.ImportInfos(
|
||||
dats,
|
||||
dialogsLayoutDid,
|
||||
RetailConfirmationDialogService.RootElementId);
|
||||
RetailDialogFactory.RootElementId(RetailDialogType.Confirmation));
|
||||
Assert.NotNull(dialogs);
|
||||
var dialogsJson = JsonSerializer.Serialize(dialogs, new JsonSerializerOptions
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.Tests.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class RetailItemConfirmationControllerTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
private const uint Pack = 0x50000002u;
|
||||
private const uint Rare = 0x50000003u;
|
||||
|
||||
[Fact]
|
||||
public void VolatileRareSendsUseOnlyAfterPositiveFactoryCallback()
|
||||
{
|
||||
var objects = BuildObjects(PublicWeenieFlags.VolatileRare);
|
||||
var uses = new List<uint>();
|
||||
var items = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: uses.Add,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
nowMs: () => 1000L);
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
ImportedLayout? shown = null;
|
||||
var factory = new RetailDialogFactory(root, _ =>
|
||||
shown = FixtureLoader.LoadConfirmationDialog());
|
||||
using var confirmations = new RetailItemConfirmationController(factory, items);
|
||||
|
||||
Assert.True(items.ActivateItem(Rare));
|
||||
Assert.Empty(uses);
|
||||
Assert.Equal(0, items.BusyCount);
|
||||
Assert.Equal(
|
||||
RetailItemConfirmationController.VolatileRareMessage,
|
||||
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
|
||||
|
||||
Assert.IsType<UiButton>(shown.FindElement(
|
||||
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
||||
|
||||
Assert.Equal([Rare], uses);
|
||||
Assert.Equal(1, items.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectedPlayerKillerAltarDoesNotSendUse()
|
||||
{
|
||||
var objects = BuildObjects(PublicWeenieFlags.PlayerKillerSwitch);
|
||||
var uses = new List<uint>();
|
||||
var items = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: uses.Add,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
nowMs: () => 1000L);
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
ImportedLayout? shown = null;
|
||||
var factory = new RetailDialogFactory(root, _ =>
|
||||
shown = FixtureLoader.LoadConfirmationDialog());
|
||||
using var confirmations = new RetailItemConfirmationController(factory, items);
|
||||
|
||||
Assert.True(items.ActivateItem(Rare));
|
||||
Assert.IsType<UiButton>(shown!.FindElement(
|
||||
RetailConfirmationDialogView.RejectButtonId)).OnClick!();
|
||||
|
||||
Assert.Empty(uses);
|
||||
Assert.Equal(0, items.BusyCount);
|
||||
}
|
||||
|
||||
private static ClientObjectTable BuildObjects(PublicWeenieFlags flags)
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Player,
|
||||
Name = "Player",
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Pack,
|
||||
Name = "Backpack",
|
||||
Type = ItemType.Container,
|
||||
});
|
||||
objects.MoveItem(Pack, Player, 0);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Rare,
|
||||
Name = "Rare",
|
||||
Type = ItemType.Misc,
|
||||
Useability = ItemUseability.Contained,
|
||||
PublicWeenieBitfield = (uint)flags,
|
||||
});
|
||||
objects.MoveItem(Rare, Pack, 0);
|
||||
return objects;
|
||||
}
|
||||
}
|
||||
|
|
@ -174,6 +174,30 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(new GameEvents.CharacterConfirmationRequest(7u, 42u, "Proceed?"), received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_ConfirmationDone_UsesRetailTypeAndContextTuple()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
GameEvents.CharacterConfirmationDone? received = null;
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
new ClientObjectTable(),
|
||||
new CombatState(),
|
||||
new Spellbook(),
|
||||
new ChatLog(),
|
||||
onConfirmationDone: done => received = done);
|
||||
byte[] payload = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 6u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 314u);
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(
|
||||
GameEventType.CharacterConfirmationDone,
|
||||
payload));
|
||||
dispatcher.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal(new GameEvents.CharacterConfirmationDone(6u, 314u), received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_FriendsUpdate_ReplacesAuthoritativeSocialState()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue