Add Init/RenderHandle concepts so components can rerender themselves arbitrarily (e.g., after internal state change)
This commit is contained in:
parent
7bb4bbbe5c
commit
695ddc0fd6
|
|
@ -20,6 +20,10 @@ namespace HostedInAspNet.Client
|
||||||
|
|
||||||
internal class MyComponent : IComponent
|
internal class MyComponent : IComponent
|
||||||
{
|
{
|
||||||
|
public void Init(RenderHandle renderHandle)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder)
|
public void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
{
|
{
|
||||||
builder.OpenElement(0, "h1");
|
builder.OpenElement(0, "h1");
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,21 @@ namespace Microsoft.AspNetCore.Blazor.Components
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class BlazorComponent : IComponent, IHandlePropertiesChanged
|
public abstract class BlazorComponent : IComponent, IHandlePropertiesChanged
|
||||||
{
|
{
|
||||||
|
private RenderHandle _renderHandle;
|
||||||
|
|
||||||
|
void IComponent.Init(RenderHandle renderHandle)
|
||||||
|
{
|
||||||
|
// This implicitly means a BlazorComponent can only be associated with a single
|
||||||
|
// renderer. That's the only use case we have right now. If there was ever a need,
|
||||||
|
// a component could hold a collection of render handles.
|
||||||
|
if (_renderHandle.IsInitalised)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"The render handle is already set. Cannot initialize a {nameof(BlazorComponent)} more than once.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderHandle = renderHandle;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public virtual void BuildRenderTree(RenderTreeBuilder builder)
|
public virtual void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@ namespace Microsoft.AspNetCore.Blazor.Components
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IComponent
|
public interface IComponent
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the component.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="renderHandle">A <see cref="RenderHandle"/> that allows the component to be rendered.</param>
|
||||||
|
void Init(RenderHandle renderHandle);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a <see cref="RenderTree"/> representing the current state of the component.
|
/// Builds a <see cref="RenderTree"/> representing the current state of the component.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Blazor.Rendering;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.Blazor.Components
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Allows a component to notify the renderer that it should be rendered.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct RenderHandle
|
||||||
|
{
|
||||||
|
private readonly Renderer _renderer;
|
||||||
|
private readonly int _componentId;
|
||||||
|
|
||||||
|
internal RenderHandle(Renderer renderer, int componentId)
|
||||||
|
{
|
||||||
|
_renderer = renderer ?? throw new System.ArgumentNullException(nameof(renderer));
|
||||||
|
_componentId = componentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value that indicates whether the <see cref="RenderHandle"/> has been
|
||||||
|
/// initialised and is ready to use.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsInitalised
|
||||||
|
=> _renderer != null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notifies the renderer that the component should be rendered.
|
||||||
|
/// </summary>
|
||||||
|
public void Render()
|
||||||
|
{
|
||||||
|
if (_renderer == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The render handle is not yet assigned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderer.ComponentRequestedRender(_componentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
|
||||||
var componentId = _nextComponentId++;
|
var componentId = _nextComponentId++;
|
||||||
var componentState = new ComponentState(this, componentId, component);
|
var componentState = new ComponentState(this, componentId, component);
|
||||||
_componentStateById.Add(componentId, componentState);
|
_componentStateById.Add(componentId, componentState);
|
||||||
|
component.Init(new RenderHandle(this, componentId));
|
||||||
return componentId;
|
return componentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,6 +156,20 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
|
||||||
frame = frame.WithAttributeEventHandlerId(id);
|
frame = frame.WithAttributeEventHandlerId(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void ComponentRequestedRender(int componentId)
|
||||||
|
{
|
||||||
|
// TODO: Clean up the locking around rendering. The Renderer doesn't really need
|
||||||
|
// to be thread-safe, and the following code isn't actually thread-safe anyway.
|
||||||
|
if (_renderBatchLock == 0)
|
||||||
|
{
|
||||||
|
RenderNewBatch(componentId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_sharedRenderBatchBuilder.ComponentRenderQueue.Enqueue(componentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ComponentState GetRequiredComponentState(int componentId)
|
private ComponentState GetRequiredComponentState(int componentId)
|
||||||
=> _componentStateById.TryGetValue(componentId, out var componentState)
|
=> _componentStateById.TryGetValue(componentId, out var componentState)
|
||||||
? componentState
|
? componentState
|
||||||
|
|
|
||||||
|
|
@ -478,6 +478,10 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test
|
||||||
|
|
||||||
public class TestComponent : IComponent
|
public class TestComponent : IComponent
|
||||||
{
|
{
|
||||||
|
public void Init(RenderHandle renderHandle)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder)
|
public void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
{
|
{
|
||||||
builder.AddText(0, $"Hello from {nameof(TestComponent)}");
|
builder.AddText(0, $"Hello from {nameof(TestComponent)}");
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,8 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
|
|
||||||
private class TestComponent : IComponent
|
private class TestComponent : IComponent
|
||||||
{
|
{
|
||||||
|
public void Init(RenderHandle renderHandle) { }
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder)
|
public void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
=> throw new NotImplementedException();
|
=> throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -798,13 +798,14 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
public string ReadonlyProperty { get; private set; }
|
public string ReadonlyProperty { get; private set; }
|
||||||
private string PrivateProperty { get; set; }
|
private string PrivateProperty { get; set; }
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder)
|
public void Init(RenderHandle renderHandle) { }
|
||||||
{
|
public void BuildRenderTree(RenderTreeBuilder builder) { }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeComponent2 : IComponent
|
private class FakeComponent2 : IComponent
|
||||||
{
|
{
|
||||||
|
public void Init(RenderHandle renderHandle) { }
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder)
|
public void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
{
|
{
|
||||||
builder.AddText(100, $"Hello from {nameof(FakeComponent2)}");
|
builder.AddText(100, $"Hello from {nameof(FakeComponent2)}");
|
||||||
|
|
@ -815,11 +816,16 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
{
|
{
|
||||||
public int DisposalCount { get; private set; }
|
public int DisposalCount { get; private set; }
|
||||||
public void Dispose() => DisposalCount++;
|
public void Dispose() => DisposalCount++;
|
||||||
|
|
||||||
|
public void Init(RenderHandle renderHandle) { }
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder) { }
|
public void BuildRenderTree(RenderTreeBuilder builder) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NonDisposableComponent : IComponent
|
private class NonDisposableComponent : IComponent
|
||||||
{
|
{
|
||||||
|
public void Init(RenderHandle renderHandle) { }
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder) { }
|
public void BuildRenderTree(RenderTreeBuilder builder) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -641,6 +641,110 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
Assert.Equal(1, eventCount);
|
Assert.Equal(1, eventCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComponentCannotTriggerRenderBeforeRenderHandleAssigned()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var component = new TestComponent(builder => { });
|
||||||
|
|
||||||
|
// Act/Assert
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
{
|
||||||
|
component.TriggerRender();
|
||||||
|
});
|
||||||
|
Assert.Equal("The render handle is not yet assigned.", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComponentCanTriggerRenderWhenNoBatchIsInProgress()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var renderer = new TestRenderer();
|
||||||
|
var renderCount = 0;
|
||||||
|
var component = new TestComponent(builder =>
|
||||||
|
{
|
||||||
|
builder.AddText(0, $"Render count: {++renderCount}");
|
||||||
|
});
|
||||||
|
var componentId = renderer.AssignComponentId(component);
|
||||||
|
|
||||||
|
// Act/Assert: Can trigger initial render
|
||||||
|
Assert.Equal(0, renderCount);
|
||||||
|
component.TriggerRender();
|
||||||
|
Assert.Equal(1, renderCount);
|
||||||
|
var batch1 = renderer.Batches.Single();
|
||||||
|
var edit1 = batch1.DiffsByComponentId[componentId].Single().Edits.Single();
|
||||||
|
Assert.Equal(RenderTreeEditType.PrependFrame, edit1.Type);
|
||||||
|
AssertFrame.Text(batch1.ReferenceFrames[edit1.ReferenceFrameIndex],
|
||||||
|
"Render count: 1", 0);
|
||||||
|
|
||||||
|
// Act/Assert: Can trigger subsequent render
|
||||||
|
component.TriggerRender();
|
||||||
|
Assert.Equal(2, renderCount);
|
||||||
|
var batch2 = renderer.Batches.Skip(1).Single();
|
||||||
|
var edit2 = batch2.DiffsByComponentId[componentId].Single().Edits.Single();
|
||||||
|
Assert.Equal(RenderTreeEditType.UpdateText, edit2.Type);
|
||||||
|
AssertFrame.Text(batch2.ReferenceFrames[edit2.ReferenceFrameIndex],
|
||||||
|
"Render count: 2", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComponentCanTriggerRenderWhenExistingBatchIsInProgress()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var renderer = new TestRenderer();
|
||||||
|
TestComponent parent = null;
|
||||||
|
var parentRenderCount = 0;
|
||||||
|
parent = new TestComponent(builder =>
|
||||||
|
{
|
||||||
|
builder.OpenComponent<ReRendersParentComponent>(0);
|
||||||
|
builder.AddAttribute(1, nameof(ReRendersParentComponent.Parent), parent);
|
||||||
|
builder.CloseComponent();
|
||||||
|
builder.AddText(2, $"Parent render count: {++parentRenderCount}");
|
||||||
|
});
|
||||||
|
var parentComponentId = renderer.AssignComponentId(parent);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
renderer.RenderNewBatch(parentComponentId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var batch = renderer.Batches.Single();
|
||||||
|
Assert.Equal(3, batch.DiffsInOrder.Count);
|
||||||
|
|
||||||
|
// First is the parent component's initial render
|
||||||
|
var diff1 = batch.DiffsInOrder[0];
|
||||||
|
Assert.Equal(parentComponentId, diff1.ComponentId);
|
||||||
|
Assert.Collection(diff1.Edits,
|
||||||
|
edit =>
|
||||||
|
{
|
||||||
|
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
|
||||||
|
AssertFrame.Component<ReRendersParentComponent>(
|
||||||
|
batch.ReferenceFrames[edit.ReferenceFrameIndex]);
|
||||||
|
},
|
||||||
|
edit =>
|
||||||
|
{
|
||||||
|
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
|
||||||
|
AssertFrame.Text(
|
||||||
|
batch.ReferenceFrames[edit.ReferenceFrameIndex],
|
||||||
|
"Parent render count: 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second is the child component's single render
|
||||||
|
var diff2 = batch.DiffsInOrder[1];
|
||||||
|
Assert.NotEqual(parentComponentId, diff2.ComponentId);
|
||||||
|
var diff2edit = diff2.Edits.Single();
|
||||||
|
Assert.Equal(RenderTreeEditType.PrependFrame, diff2edit.Type);
|
||||||
|
AssertFrame.Text(batch.ReferenceFrames[diff2edit.ReferenceFrameIndex],
|
||||||
|
"Child is here");
|
||||||
|
|
||||||
|
// Third is the parent's triggered render
|
||||||
|
var diff3 = batch.DiffsInOrder[2];
|
||||||
|
Assert.Equal(parentComponentId, diff3.ComponentId);
|
||||||
|
var diff3edit = diff3.Edits.Single();
|
||||||
|
Assert.Equal(RenderTreeEditType.UpdateText, diff3edit.Type);
|
||||||
|
AssertFrame.Text(batch.ReferenceFrames[diff3edit.ReferenceFrameIndex],
|
||||||
|
"Parent render count: 2");
|
||||||
|
}
|
||||||
|
|
||||||
private class NoOpRenderer : Renderer
|
private class NoOpRenderer : Renderer
|
||||||
{
|
{
|
||||||
public new int AssignComponentId(IComponent component)
|
public new int AssignComponentId(IComponent component)
|
||||||
|
|
@ -690,6 +794,9 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
public IDictionary<int, List<RenderTreeDiff>> DiffsByComponentId { get; }
|
public IDictionary<int, List<RenderTreeDiff>> DiffsByComponentId { get; }
|
||||||
= new Dictionary<int, List<RenderTreeDiff>>();
|
= new Dictionary<int, List<RenderTreeDiff>>();
|
||||||
|
|
||||||
|
public IList<RenderTreeDiff> DiffsInOrder { get; }
|
||||||
|
= new List<RenderTreeDiff>();
|
||||||
|
|
||||||
public IList<int> DisposedComponentIDs { get; set; }
|
public IList<int> DisposedComponentIDs { get; set; }
|
||||||
public RenderTreeFrame[] ReferenceFrames { get; set; }
|
public RenderTreeFrame[] ReferenceFrames { get; set; }
|
||||||
|
|
||||||
|
|
@ -702,14 +809,17 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone the diff, because its underlying storage will get reused in subsequent batches
|
// Clone the diff, because its underlying storage will get reused in subsequent batches
|
||||||
DiffsByComponentId[componentId].Add(new RenderTreeDiff(
|
var diffClone = new RenderTreeDiff(
|
||||||
diff.ComponentId,
|
diff.ComponentId,
|
||||||
new ArraySegment<RenderTreeEdit>(diff.Edits.ToArray())));
|
new ArraySegment<RenderTreeEdit>(diff.Edits.ToArray()));
|
||||||
|
DiffsByComponentId[componentId].Add(diffClone);
|
||||||
|
DiffsInOrder.Add(diffClone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class TestComponent : IComponent
|
private class TestComponent : IComponent
|
||||||
{
|
{
|
||||||
|
private RenderHandle _renderHandle;
|
||||||
private Action<RenderTreeBuilder> _renderAction;
|
private Action<RenderTreeBuilder> _renderAction;
|
||||||
|
|
||||||
public TestComponent(Action<RenderTreeBuilder> renderAction)
|
public TestComponent(Action<RenderTreeBuilder> renderAction)
|
||||||
|
|
@ -717,14 +827,26 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
_renderAction = renderAction;
|
_renderAction = renderAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Init(RenderHandle renderHandle)
|
||||||
|
{
|
||||||
|
_renderHandle = renderHandle;
|
||||||
|
}
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder)
|
public void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
=> _renderAction(builder);
|
=> _renderAction(builder);
|
||||||
|
|
||||||
|
public void TriggerRender()
|
||||||
|
=> _renderHandle.Render();
|
||||||
}
|
}
|
||||||
|
|
||||||
private class MessageComponent : IComponent
|
private class MessageComponent : IComponent
|
||||||
{
|
{
|
||||||
public string Message { get; set; }
|
public string Message { get; set; }
|
||||||
|
|
||||||
|
public void Init(RenderHandle renderHandle)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder)
|
public void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
{
|
{
|
||||||
builder.AddText(0, Message);
|
builder.AddText(0, Message);
|
||||||
|
|
@ -737,6 +859,10 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
public string StringProperty { get; set; }
|
public string StringProperty { get; set; }
|
||||||
public object ObjectProperty { get; set; }
|
public object ObjectProperty { get; set; }
|
||||||
|
|
||||||
|
public void Init(RenderHandle renderHandle)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder)
|
public void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -747,6 +873,10 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
public UIEventHandler Handler { get; set; }
|
public UIEventHandler Handler { get; set; }
|
||||||
public bool SkipElement { get; set; }
|
public bool SkipElement { get; set; }
|
||||||
|
|
||||||
|
public void Init(RenderHandle renderHandle)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder)
|
public void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
{
|
{
|
||||||
builder.OpenElement(0, "grandparent");
|
builder.OpenElement(0, "grandparent");
|
||||||
|
|
@ -770,6 +900,10 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
public bool IncludeChild { get; set; }
|
public bool IncludeChild { get; set; }
|
||||||
public IDictionary<string, object> ChildParameters { get; set; }
|
public IDictionary<string, object> ChildParameters { get; set; }
|
||||||
|
|
||||||
|
public void Init(RenderHandle renderHandle)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder)
|
public void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
{
|
{
|
||||||
builder.AddText(0, "Parent here");
|
builder.AddText(0, "Parent here");
|
||||||
|
|
@ -796,6 +930,10 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
|
|
||||||
public int IntProperty { get; set; }
|
public int IntProperty { get; set; }
|
||||||
|
|
||||||
|
public void Init(RenderHandle renderHandle)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public void BuildRenderTree(RenderTreeBuilder builder)
|
public void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
{
|
{
|
||||||
builder.AddText(0, $"Notifications: {NotificationsCount}");
|
builder.AddText(0, $"Notifications: {NotificationsCount}");
|
||||||
|
|
@ -807,20 +945,23 @@ namespace Microsoft.AspNetCore.Blazor.Test
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(int, T) FirstWithIndex<T>(IEnumerable<T> items, Predicate<T> predicate)
|
private class ReRendersParentComponent : IComponent
|
||||||
{
|
{
|
||||||
var index = 0;
|
public TestComponent Parent { get; set; }
|
||||||
foreach (var item in items)
|
private bool _isFirstTime = true;
|
||||||
|
|
||||||
|
public void Init(RenderHandle renderHandle) { }
|
||||||
|
|
||||||
|
public void BuildRenderTree(RenderTreeBuilder builder)
|
||||||
{
|
{
|
||||||
if (predicate(item))
|
if (_isFirstTime) // Don't want an infinite loop
|
||||||
{
|
{
|
||||||
return (index, item);
|
_isFirstTime = false;
|
||||||
|
Parent.TriggerRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
index++;
|
builder.AddText(0, "Child is here");
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new InvalidOperationException("No matching element was found.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue