Add workflow definition count endpoint + unique name validation
This commit is contained in:
parent
9bc77b9931
commit
eabe853fe7
|
|
@ -1,5 +1,6 @@
|
|||
using Elsa.Api.Client.Models;
|
||||
using Elsa.Api.Client.Resources.WorkflowDefinitions.Models;
|
||||
using JetBrains.Annotations;
|
||||
using Refit;
|
||||
|
||||
namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Contracts;
|
||||
|
|
@ -7,6 +8,7 @@ namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Contracts;
|
|||
/// <summary>
|
||||
/// Represents a client for the workflow definitions API.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
public interface IWorkflowDefinitionsApi
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -27,8 +29,24 @@ public interface IWorkflowDefinitionsApi
|
|||
/// <param name="includeCompositeRoot">Whether to include the root activity of composite activities.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The workflow definition.</returns>
|
||||
[Get("/workflow-definitions/{definitionId}?versionOptions={VersionOptions}&includeCompositeRoot={includeCompositeRoot}")]
|
||||
[Get("/workflow-definitions/{definitionId}?versionOptions={versionOptions}&includeCompositeRoot={includeCompositeRoot}")]
|
||||
Task<WorkflowDefinition?> GetAsync(string definitionId, VersionOptions? versionOptions = default, bool includeCompositeRoot = false, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of workflow definitions.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
[Get("/workflow-definitions/query/count")]
|
||||
Task<CountWorkflowDefinitionsResponse> CountAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a workflow definition name is unique.
|
||||
/// </summary>
|
||||
/// <param name="name">The name to check.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A response containing a value indicating whether the name is unique.</returns>
|
||||
[Get("/workflow-definitions/validation/is-name-unique?name={name}")]
|
||||
Task<GetIsNameUniqueResponse> GetIsNameUniqueAsync(string name, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a workflow definition.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A response containing the number of workflow definitions.
|
||||
/// </summary>
|
||||
public record CountWorkflowDefinitionsResponse(long Count);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace Elsa.Api.Client.Resources.WorkflowDefinitions.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A response containing a value indicating whether a workflow definition name is unique.
|
||||
/// </summary>
|
||||
/// <param name="IsUnique"><c>true</c> if the name is unique, otherwise false.</param>
|
||||
public record GetIsNameUniqueResponse(bool IsUnique);
|
||||
|
|
@ -152,4 +152,17 @@ public class MemoryStore<TEntity>
|
|||
/// <param name="predicate">The predicate to match.</param>
|
||||
/// <returns>True if any entity matches the specified predicate, otherwise false.</returns>
|
||||
public bool AnyAsync(Func<TEntity, bool> predicate) => Entities.Values.Any(predicate);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of entities matching the specified predicate.
|
||||
/// </summary>
|
||||
/// <param name="predicate">The predicate to match.</param>
|
||||
/// <param name="propertySelector">The property to distinct by.</param>
|
||||
/// <returns>True if any entity matches the specified predicate, otherwise false.</returns>
|
||||
public long CountAsync<TProperty>(Func<TEntity, bool> predicate, Func<TEntity, TProperty> propertySelector)
|
||||
{
|
||||
return Entities.Values
|
||||
.DistinctBy(propertySelector)
|
||||
.Count(predicate);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,10 @@ public abstract class SqlDialectBase : ISqlDialect
|
|||
public string Delete(string table) => $"delete from {table} where 1=1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Count(string table) => $"select COUNT(*) from {table} where 1=1";
|
||||
public string Count(string table) => Count("*", table);
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Count(string fieldExpression, string table) => $"select COUNT({fieldExpression}) from {table} where 1=1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual string And(string field) => $"and {field} = @{field}";
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ public interface ISqlDialect
|
|||
/// </summary>
|
||||
/// <param name="table">The table to query.</param>
|
||||
string Count(string table);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a SELECT count(fieldExpression) FROM query.
|
||||
/// </summary>
|
||||
/// <param name="fieldExpression">The field expression to query. Examples: "*", "distinct FieldName"</param>
|
||||
/// <param name="table">The table to query.</param>
|
||||
string Count(string fieldExpression, string table);
|
||||
|
||||
/// <summary>
|
||||
/// Returns an AND clause.
|
||||
|
|
|
|||
|
|
@ -81,6 +81,18 @@ public static class ParameterizedQueryBuilderExtensions
|
|||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins a SELECT COUNT(expression) FROM query.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <param name="expression">The expression. Examples: "*" or "DISTINCT FieldName"</param>
|
||||
/// <param name="table">The table.</param>
|
||||
public static ParameterizedQuery Count(this ParameterizedQuery query, string expression, string table)
|
||||
{
|
||||
query.Sql.AppendLine(query.Dialect.Count(expression, table));
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends an AND clause to the query if the value is not null.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -142,6 +142,14 @@ public class DapperWorkflowDefinitionStore : IWorkflowDefinitionStore
|
|||
return await _store.AnyAsync(q => ApplyFilter(q, filter), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long> CountDistinctAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _store.CountAsync(
|
||||
filter => filter.Count($"distinct {nameof(WorkflowDefinition.DefinitionId)}", TableName),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private void ApplyFilter(ParameterizedQuery query, WorkflowDefinitionFilter filter)
|
||||
{
|
||||
ParameterizedQueryBuilderExtensions.Equals(query
|
||||
|
|
|
|||
|
|
@ -305,4 +305,33 @@ public class Store<TDbContext, TEntity> where TDbContext : DbContext where TEnti
|
|||
var set = dbContext.Set<TEntity>();
|
||||
return await set.AnyAsync(predicate, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of entities matching a predicate.
|
||||
/// </summary>
|
||||
/// <param name="predicate">The predicate.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public async Task<long> CountAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var dbContext = await CreateDbContextAsync(cancellationToken);
|
||||
var set = dbContext.Set<TEntity>();
|
||||
return await set.CountAsync(predicate, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the distinct number of entities matching a predicate.
|
||||
/// </summary>
|
||||
/// <param name="predicate">The predicate.</param>
|
||||
/// <param name="propertySelector">The property selector to distinct by.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public async Task<long> CountAsync<TProperty>(Expression<Func<TEntity, bool>> predicate, Expression<Func<TEntity, TProperty>> propertySelector, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var dbContext = await CreateDbContextAsync(cancellationToken);
|
||||
var set = dbContext.Set<TEntity>();
|
||||
return await set
|
||||
.Where(predicate)
|
||||
.Select(propertySelector)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +141,12 @@ public class EFCoreWorkflowDefinitionStore : IWorkflowDefinitionStore
|
|||
/// <inheritdoc />
|
||||
public async Task<bool> AnyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => await _store.QueryAsync(queryable => Filter(queryable, filter), cancellationToken).Any();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long> CountDistinctAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _store.CountAsync(x => true, x => x.DefinitionId, cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask<WorkflowDefinition> SaveAsync(ManagementElsaDbContext managementElsaDbContext, WorkflowDefinition entity, CancellationToken cancellationToken)
|
||||
{
|
||||
var data = new WorkflowDefinitionState(entity.Options, entity.Variables, entity.Inputs, entity.Outputs, entity.Outcomes, entity.CustomProperties);
|
||||
|
|
|
|||
|
|
@ -133,6 +133,12 @@ public class MongoDbStore<TDocument> where TDocument : class
|
|||
public async Task<long> CountAsync(Func<IMongoQueryable<TDocument>, IMongoQueryable<TDocument>> query, CancellationToken cancellationToken = default) =>
|
||||
await query(_collection.AsQueryable()).LongCountAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Counts documents in the collection using a filter and distinct by a key selector.
|
||||
/// </summary>
|
||||
public async Task<long> CountAsync<TProperty>(Func<IMongoQueryable<TDocument>, IMongoQueryable<TDocument>> query, Expression<Func<TDocument, TProperty>> propertySelector, CancellationToken cancellationToken = default) =>
|
||||
await query((IMongoQueryable<TDocument>)_collection.AsQueryable().DistinctBy(propertySelector)).LongCountAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any documents exist.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -59,11 +59,11 @@ public class MongoWorkflowDefinitionStore : IWorkflowDefinitionStore
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<WorkflowDefinition>> FindManyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) =>
|
||||
public async Task<IEnumerable<WorkflowDefinition>> FindManyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) =>
|
||||
await _mongoDbStore.FindManyAsync(queryable => Filter(queryable, filter), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<WorkflowDefinition>> FindManyAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
|
||||
public async Task<IEnumerable<WorkflowDefinition>> FindManyAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
|
||||
await _mongoDbStore.FindManyAsync(queryable => Order(Filter(queryable, filter), order), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -106,11 +106,11 @@ public class MongoWorkflowDefinitionStore : IWorkflowDefinitionStore
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) =>
|
||||
public async Task SaveAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default) =>
|
||||
await _mongoDbStore.SaveAsync(definition, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveManyAsync(IEnumerable<WorkflowDefinition> definitions, CancellationToken cancellationToken = default) =>
|
||||
public async Task SaveManyAsync(IEnumerable<WorkflowDefinition> definitions, CancellationToken cancellationToken = default) =>
|
||||
await _mongoDbStore.SaveManyAsync(definitions.Select(i => i), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -123,18 +123,26 @@ public class MongoWorkflowDefinitionStore : IWorkflowDefinitionStore
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> AnyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) =>
|
||||
await _mongoDbStore.FindManyAsync(queryable => Filter(queryable, filter), cancellationToken).Any();
|
||||
public async Task<bool> AnyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _mongoDbStore.FindManyAsync(queryable => Filter(queryable, filter), cancellationToken).Any();
|
||||
}
|
||||
|
||||
private IMongoQueryable<WorkflowDefinition> Filter(IMongoQueryable<WorkflowDefinition> queryable, WorkflowDefinitionFilter filter) =>
|
||||
/// <inheritdoc />
|
||||
public async Task<long> CountDistinctAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _mongoDbStore.CountAsync(queryable => queryable, x => x.DefinitionId, cancellationToken);
|
||||
}
|
||||
|
||||
private IMongoQueryable<WorkflowDefinition> Filter(IMongoQueryable<WorkflowDefinition> queryable, WorkflowDefinitionFilter filter) =>
|
||||
(filter.Apply(queryable) as IMongoQueryable<WorkflowDefinition>)!;
|
||||
|
||||
private IMongoQueryable<WorkflowDefinition> Order<TOrderBy>(IMongoQueryable<WorkflowDefinition> queryable, WorkflowDefinitionOrder<TOrderBy> order) =>
|
||||
private IMongoQueryable<WorkflowDefinition> Order<TOrderBy>(IMongoQueryable<WorkflowDefinition> queryable, WorkflowDefinitionOrder<TOrderBy> order) =>
|
||||
(queryable.OrderBy(order) as IMongoQueryable<WorkflowDefinition>)!;
|
||||
|
||||
private IMongoQueryable<WorkflowDefinition> Paginate(IMongoQueryable<WorkflowDefinition> queryable, PageArgs pageArgs) =>
|
||||
|
||||
private IMongoQueryable<WorkflowDefinition> Paginate(IMongoQueryable<WorkflowDefinition> queryable, PageArgs pageArgs) =>
|
||||
(queryable.Paginate(pageArgs) as IMongoQueryable<WorkflowDefinition>)!;
|
||||
|
||||
private IMongoQueryable<WorkflowDefinition> OrderAndPaginate<TOrderBy>(IMongoQueryable<WorkflowDefinition> queryable, WorkflowDefinitionOrder<TOrderBy> order, PageArgs pageArgs) =>
|
||||
|
||||
private IMongoQueryable<WorkflowDefinition> OrderAndPaginate<TOrderBy>(IMongoQueryable<WorkflowDefinition> queryable, WorkflowDefinitionOrder<TOrderBy> order, PageArgs pageArgs) =>
|
||||
(queryable.OrderBy(order).Paginate(pageArgs) as IMongoQueryable<WorkflowDefinition>)!;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Common.Entities;
|
||||
using Elsa.Common.Models;
|
||||
using Elsa.Workflows.Core.Contracts;
|
||||
using Elsa.Workflows.Core.Serialization.Converters;
|
||||
using Elsa.Workflows.Management.Contracts;
|
||||
using Elsa.Workflows.Management.Filters;
|
||||
using Elsa.Workflows.Management.Mappers;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Count;
|
||||
|
||||
/// <summary>
|
||||
/// An endpoint for counting workflow definitions.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
internal class Count : ElsaEndpointWithoutRequest<Response>
|
||||
{
|
||||
private readonly IWorkflowDefinitionStore _store;
|
||||
|
||||
public Count(IWorkflowDefinitionStore store)
|
||||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workflow-definitions/query/count");
|
||||
ConfigurePermissions("read:workflow-definitions");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var count = await _store.CountDistinctAsync(cancellationToken);
|
||||
var response = new Response(count);
|
||||
await SendOkAsync(response, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.Count;
|
||||
|
||||
internal record Response(long Count);
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
using Elsa.Abstractions;
|
||||
using Elsa.Common.Entities;
|
||||
using Elsa.Common.Models;
|
||||
using Elsa.Workflows.Core.Contracts;
|
||||
using Elsa.Workflows.Core.Serialization.Converters;
|
||||
using Elsa.Workflows.Management.Contracts;
|
||||
using Elsa.Workflows.Management.Filters;
|
||||
using Elsa.Workflows.Management.Mappers;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.IsNameUnique;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a workflow definition name is unique.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
internal class IsNameUnique : ElsaEndpoint<Request>
|
||||
{
|
||||
private readonly IWorkflowDefinitionStore _store;
|
||||
|
||||
public IsNameUnique(IWorkflowDefinitionStore store)
|
||||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/workflow-definitions/validation/is-name-unique");
|
||||
ConfigurePermissions("read:workflow-definitions");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
var filter = new WorkflowDefinitionFilter
|
||||
{
|
||||
Name = request.Name.Trim()
|
||||
};
|
||||
|
||||
var exists = await _store.AnyAsync(filter, cancellationToken);
|
||||
var response = new Response(!exists);
|
||||
|
||||
await SendOkAsync(response, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace Elsa.Workflows.Api.Endpoints.WorkflowDefinitions.IsNameUnique;
|
||||
|
||||
internal class Request
|
||||
{
|
||||
public string Name { get; set; } = default!;
|
||||
}
|
||||
|
||||
internal record Response(bool IsUnique);
|
||||
|
|
@ -4,7 +4,13 @@ using Microsoft.AspNetCore.Builder;
|
|||
// ReSharper disable once CheckNamespace
|
||||
namespace Elsa.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extensions to <see cref="IApplicationBuilder"/>.
|
||||
/// </summary>
|
||||
public static class ApplicationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds middleware that handles JSON serialization errors.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseJsonSerializationErrorHandler(this IApplicationBuilder app) => app.UseMiddleware<JsonSerializationErrorHandlerMiddleware>();
|
||||
}
|
||||
|
|
@ -4,8 +4,14 @@ using Elsa.Workflows.Api.Features;
|
|||
// ReSharper disable once CheckNamespace
|
||||
namespace Elsa.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extensions to <see cref="IModule"/>.
|
||||
/// </summary>
|
||||
public static class ModuleExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures the workflows API feature.
|
||||
/// </summary>
|
||||
public static IModule UseWorkflowsApi(this IModule module, Action<WorkflowsApiFeature>? configure = default)
|
||||
{
|
||||
module.Configure(configure);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,14 @@ using Elsa.Workflows.Api.Features;
|
|||
// ReSharper disable once CheckNamespace
|
||||
namespace Elsa.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extensions to <see cref="WorkflowsApiFeature"/>.
|
||||
/// </summary>
|
||||
public static class WorkflowsApiFeatureExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds FastEndpoint endpoints from the assembly containing <typeparamref name="TMarker"/>.
|
||||
/// </summary>
|
||||
public static WorkflowsApiFeature AddFastEndpointsAssembly<TMarker>(this WorkflowsApiFeature feature)
|
||||
{
|
||||
feature.Module.AddFastEndpointsAssembly<TMarker>();
|
||||
|
|
|
|||
|
|
@ -141,4 +141,9 @@ public interface IWorkflowDefinitionStore
|
|||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>True if any workflow definition matches the specified filter.</returns>
|
||||
Task<bool> AnyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of logical workflow definitions.
|
||||
/// </summary>
|
||||
Task<long> CountDistinctAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -137,7 +137,12 @@ public class MemoryWorkflowDefinitionStore : IWorkflowDefinitionStore
|
|||
var exists = _store.Query(query => Filter(query, filter)).Any();
|
||||
return Task.FromResult(exists);
|
||||
}
|
||||
|
||||
|
||||
public Task<long> CountDistinctAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private IQueryable<WorkflowDefinition> Filter(IQueryable<WorkflowDefinition> queryable, WorkflowDefinitionFilter filter) => filter.Apply(queryable);
|
||||
|
||||
private string GetId(WorkflowDefinition workflowDefinition) => workflowDefinition.Id;
|
||||
|
|
|
|||
Loading…
Reference in a new issue