Enhanced Workflow Instance Filters (#4957)

* Add timestamp filtering for workflow instances

Implemented a new feature in the workflow instances' querying API, enabling filtering based on timestamps. The changes include adding a new TimestampFilter class and modifying existing classes accordingly. This new filter supports various operators such as greater than and less than, providing flexibility in filtering workflow instances.

* Improve timestamp filtering efficiency and update UI in WorkflowInstanceList

Timestamp filtering within WorkflowInstanceList is refactored using System.Linq.Dynamic.Core for more compact expression. This change results in simpler and more efficient code, reducing repetitive code lines. The UI updates include changing the MudGrid structure to table for timestamp filters display, offering a more organized view. Also, protection against null or minimum date values during parsing and comparisons is added, increasing stability and safety of operations.

* Refactor HTTP verbs and routes configuration in Endpoint.cs

The previous Get and Post method calls for handling '/workflow-instances' have been replaced with a Verbs method for HTTP verbs and a Routes method for setting the route.

* Update WorkflowInstanceFilter to use nullable HasIncidents

The HasIncidents property in WorkflowInstanceFilter has been updated from a bool to a nullable bool so that it can represent three states (true, false, or null) instead of only two. This allows not only to filter workflow instances that have incidents, but also those that don't have any, enhancing the flexibility of the filter in the process.

* Improve search functionality and UI in WorkflowInstanceList

Updated search term comparison in WorkflowInstanceFilter to use "Contains" instead of "Equals" to improve search flexibility. Also, enhanced the user interface in WorkflowInstanceList by adding debounce interval for the search input for performance reasons and by adding a Close button for convenience.

* Add input validation to WorkflowInstances endpoint

The code modifies the API endpoint for WorkflowInstances. It now checks that the input provided in the request is valid before proceeding. Furthermore, a new file called 'RequiredMembers.cs' has been added, which introduces two new attributes 'RequiredMemberAttribute' and 'CompilerFeatureRequiredAttribute'.

* Update src/modules/Elsa.Workflows.Management/Models/TimestampFilter.cs

Co-authored-by: Béchir BEN AMEUR <32399944+bbenameur@users.noreply.github.com>

* Update src/modules/Elsa.Workflows.Management/Models/TimestampFilter.cs

Co-authored-by: Béchir BEN AMEUR <32399944+bbenameur@users.noreply.github.com>

* Update src/modules/Elsa.Workflows.Management/Models/TimestampFilter.cs

Co-authored-by: Béchir BEN AMEUR <32399944+bbenameur@users.noreply.github.com>

---------

Co-authored-by: Béchir BEN AMEUR <32399944+bbenameur@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2024-02-18 23:40:03 +01:00 committed by GitHub
parent 01c34e6b13
commit c80f80fd81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 288 additions and 67 deletions

View file

@ -87,6 +87,7 @@
<PackageVersion Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageVersion Include="System.Data.SqlClient" Version="4.8.6" />
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
<PackageVersion Include="System.Linq.Dynamic.Core" Version="1.3.8" />
<PackageVersion Include="Testcontainers" Version="3.6.0" />
<PackageVersion Include="Testcontainers.Redis" Version="3.6.0" />
<PackageVersion Include="ThrottleDebounce" Version="2.0.0" />

View file

@ -3,8 +3,14 @@
<packageSources>
<clear />
<add key="NuGet official package source" value="https://api.nuget.org/v3/index.json" />
<!--
<add key="Elsa 3 Preview" value="https://f.feedz.io/elsa-workflows/elsa-3/nuget/index.json" />
-->
</packageSources>
<packageSourceMapping>
<packageSource key="NuGet official package source">
<package pattern="*" />
</packageSource>
<packageSource key="Elsa 3 Preview">
<package pattern="Elsa.*" />
</packageSource>
</packageSourceMapping>
</configuration>

View file

@ -14,7 +14,7 @@ public interface IWorkflowInstancesApi
/// <summary>
/// Returns a list of workflow instances.
/// </summary>
[Get("/workflow-instances")]
[Post("/workflow-instances")]
Task<PagedListResponse<WorkflowInstanceSummary>> ListAsync(ListWorkflowInstancesRequest request, CancellationToken cancellationToken = default);
/// <summary>

View file

@ -13,64 +13,74 @@ public class ListWorkflowInstancesRequest
/// Gets or sets the page number.
/// </summary>
public int? Page { get; set; }
/// <summary>
/// Gets or sets the page size.
/// </summary>
public int? PageSize { get; set; }
/// <summary>
/// Gets or sets the search term.
/// </summary>
public string? SearchTerm { get; set; }
/// <summary>
/// Gets or sets the workflow definition id.
/// </summary>
public string? DefinitionId { get; set; }
/// <summary>
/// Gets or sets the workflow definition ids.
/// </summary>
[Query(CollectionFormat.Multi)] public ICollection<string>? DefinitionIds { get; set; }
public ICollection<string>? DefinitionIds { get; set; }
/// <summary>
/// Gets or sets the correlation id.
/// </summary>
public string? CorrelationId { get; set; }
/// <summary>
/// Gets or sets the version.
/// </summary>
public int? Version { get; set; }
/// <summary>
/// Gets or sets whether the workflow instances have incidents or not.
/// </summary>
public bool? HasIncidents { get; set; }
/// <summary>
/// Gets or sets the status.
/// </summary>
public WorkflowStatus? Status { get; set; }
/// <summary>
/// Gets or sets the statuses.
/// </summary>
[Query(CollectionFormat.Multi)] public ICollection<WorkflowStatus>? Statuses { get; set; }
public ICollection<WorkflowStatus>? Statuses { get; set; }
/// <summary>
/// Gets or sets the sub status.
/// </summary>
public WorkflowSubStatus? SubStatus { get; set; }
/// <summary>
/// Gets or sets the sub statuses.
/// </summary>
[Query(CollectionFormat.Multi)] public ICollection<WorkflowSubStatus>? SubStatuses { get; set; }
public ICollection<WorkflowSubStatus>? SubStatuses { get; set; }
/// <summary>
/// Gets or sets the key to order by.
/// </summary>
public OrderByWorkflowInstance? OrderBy { get; set; }
/// <summary>
/// Gets or sets the order direction.
/// </summary>
public OrderDirection? OrderDirection { get; set; }
/// <summary>
/// Gets or sets the timestamp filters.
/// </summary>
public ICollection<TimestampFilter>? TimestampFilters { get; set; }
}

View file

@ -0,0 +1,37 @@
namespace Elsa.Api.Client.Shared.Enums;
/// <summary>
/// Specifies the operators for filtering timestamps in a TimestampFilter object.
/// </summary>
public enum TimestampFilterOperator
{
/// <summary>
/// The timestamp is equal to the specified value.
/// </summary>
Is,
/// <summary>
/// The timestamp is not equal to the specified value.
/// </summary>
IsNot,
/// <summary>
/// The timestamp is before the specified value.
/// </summary>
LessThan,
/// <summary>
/// The timestamp is greater than the specified value.
/// </summary>
GreaterThan,
/// <summary>
/// The timestamp is less than or equal to the specified value.
/// </summary>
LessThanOrEqual,
/// <summary>
/// The timestamp is greater than or equal to the specified value.
/// </summary>
GreaterThanOrEqual
}

View file

@ -0,0 +1,26 @@
using Elsa.Api.Client.Shared.Enums;
using JetBrains.Annotations;
namespace Elsa.Api.Client.Shared.Models;
/// <summary>
/// Represents a timestamp filter used for filtering data based on a specified timestamp column and operator.
/// </summary>
[UsedImplicitly]
public class TimestampFilter
{
/// <summary>
/// Gets or sets the column to filter by.
/// </summary>
public string Column { get; set; } = default!;
/// <summary>
/// Gets or sets the operator to use for filtering.
/// </summary>
public TimestampFilterOperator Operator { get; set; }
/// <summary>
/// Gets or sets the timestamp to filter by.
/// </summary>
public DateTimeOffset Timestamp { get; set; }
}

View file

@ -6,57 +6,98 @@ using Elsa.Workflows.Management.Contracts;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Management.Models;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Http;
namespace Elsa.Workflows.Api.Endpoints.WorkflowInstances.List;
[PublicAPI]
internal class List : ElsaEndpoint<Request, Response>
[UsedImplicitly]
internal class List(IWorkflowInstanceStore store) : ElsaEndpoint<Request, Response>
{
private readonly IWorkflowInstanceStore _store;
public List(IWorkflowInstanceStore store)
{
_store = store;
}
public override void Configure()
{
Get("/workflow-instances");
Verbs(FastEndpoints.Http.GET, FastEndpoints.Http.POST);
Routes("/workflow-instances");
ConfigurePermissions("read:workflow-instances");
}
public override async Task<Response> ExecuteAsync(Request request, CancellationToken cancellationToken)
public override async Task HandleAsync(Request request, CancellationToken cancellationToken)
{
var pageArgs = PageArgs.FromPage(request.Page, request.PageSize);
if (!await ValidateInputAsync(request, cancellationToken))
{
await SendErrorsAsync(StatusCodes.Status400BadRequest, cancellationToken);
return;
}
var filter = new WorkflowInstanceFilter
{
SearchTerm = request.SearchTerm,
DefinitionId = request.DefinitionId,
DefinitionIds = request.DefinitionIds,
DefinitionIds = request.DefinitionIds?.Any() == true ? request.DefinitionIds : null,
Version = request.Version,
CorrelationId = request.CorrelationId,
WorkflowStatus = request.Status,
WorkflowSubStatus = request.SubStatus,
WorkflowStatuses = request.Statuses?.Select(Enum.Parse<WorkflowStatus>).ToList(),
WorkflowSubStatuses = request.SubStatuses?.Select(Enum.Parse<WorkflowSubStatus>).ToList(),
ToCreatedAt = request.ToCreatedAt,
FromCreatedAt = request.FromCreatedAt,
WorkflowStatuses = request.Statuses?.Any() == true ? request.Statuses : null,
WorkflowSubStatuses = request.SubStatuses?.Any() == true ? request.SubStatuses : null,
HasIncidents = request.HasIncidents,
TimestampFilters = request.TimestampFilters?.Any() == true ? request.TimestampFilters : null,
};
var summaries = await FindAsync(request, filter, pageArgs, cancellationToken);
return new Response(summaries.Items, summaries.TotalCount);
var response = new Response(summaries.Items, summaries.TotalCount);
await SendOkAsync(response, cancellationToken);
}
private async Task<bool> ValidateInputAsync(Request request, CancellationToken cancellationToken)
{
if (request.Page is < 0)
{
AddError("Page must be greater than or equal to 1.");
return false;
}
if (request.PageSize is < 1)
{
AddError("Page size must be greater than or equal to 1.");
return false;
}
var columnWhitelist = new[]
{
"CreatedAt", "UpdatedAt", "FinishedAt"
};
if (request.TimestampFilters?.Any() == true)
{
foreach (var timestampFilter in request.TimestampFilters)
{
if (string.IsNullOrWhiteSpace(timestampFilter.Column))
{
AddError("Column must be specified.");
return false;
}
if (!columnWhitelist.Contains(timestampFilter.Column))
{
AddError($"Invalid column '{timestampFilter.Column}'.");
return false;
}
}
}
return true;
}
private async Task<Page<WorkflowInstanceSummary>> FindAsync(Request request, WorkflowInstanceFilter filter, PageArgs pageArgs, CancellationToken cancellationToken)
{
request.OrderBy = request.OrderBy ?? OrderByWorkflowInstance.Created;
var direction = request.OrderBy == OrderByWorkflowInstance.Name ? (request.OrderDirection ?? OrderDirection.Ascending) : (request.OrderDirection ?? OrderDirection.Descending);
request.OrderBy ??= OrderByWorkflowInstance.Created;
var direction = request.OrderBy == OrderByWorkflowInstance.Name ? request.OrderDirection ?? OrderDirection.Ascending : request.OrderDirection ?? OrderDirection.Descending;
switch (request.OrderBy)
{
default:
case OrderByWorkflowInstance.Created:
{
var o = new WorkflowInstanceOrder<DateTimeOffset>
{
@ -64,7 +105,7 @@ internal class List : ElsaEndpoint<Request, Response>
Direction = direction
};
return await _store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken);
return await store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken);
}
case OrderByWorkflowInstance.UpdatedAt:
{
@ -74,7 +115,7 @@ internal class List : ElsaEndpoint<Request, Response>
Direction = direction
};
return await _store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken);
return await store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken);
}
case OrderByWorkflowInstance.Finished:
{
@ -84,7 +125,7 @@ internal class List : ElsaEndpoint<Request, Response>
Direction = direction
};
return await _store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken);
return await store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken);
}
case OrderByWorkflowInstance.Name:
{
@ -94,7 +135,7 @@ internal class List : ElsaEndpoint<Request, Response>
Direction = direction
};
return await _store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken);
return await store.SummarizeManyAsync(filter, pageArgs, o, cancellationToken);
}
}
}

View file

@ -12,25 +12,21 @@ public class Request
public string? DefinitionId { get; set; }
public ICollection<string>? DefinitionIds { get; set; }
public string? CorrelationId { get; set; }
public string? Name { get; set; }
public int? Version { get; set; }
public bool? HasIncidents { get; set; }
public WorkflowStatus? Status { get; set; }
public ICollection<string>? Statuses { get; set; }
public ICollection<WorkflowStatus>? Statuses { get; set; }
public WorkflowSubStatus? SubStatus { get; set; }
public ICollection<string>? SubStatuses { get; set; }
public ICollection<WorkflowSubStatus>? SubStatuses { get; set; }
public OrderByWorkflowInstance? OrderBy { get; set; }
public OrderDirection? OrderDirection { get; set; }
public DateTimeOffset? FromCreatedAt { get; set; }
public DateTimeOffset? ToCreatedAt { get; set; }
public ICollection<TimestampFilter>? TimestampFilters { get; set; }
}
public class Response
internal class Response(ICollection<WorkflowInstanceSummary> items, long totalCount)
{
public Response(ICollection<WorkflowInstanceSummary> items, long totalCount)
{
Items = items;
TotalCount = totalCount;
}
public ICollection<WorkflowInstanceSummary> Items { get; set; } = items;
public long TotalCount { get; set; } = totalCount;
}
public ICollection<WorkflowInstanceSummary> Items { get; set; }
public long TotalCount { get; set; }
}

View file

@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Humanizer.Core" />
<PackageReference Include="IronCompress" />
<PackageReference Include="System.Linq.Async" />
<PackageReference Include="System.Linq.Dynamic.Core" />
</ItemGroup>
<ItemGroup>

View file

@ -0,0 +1,37 @@
namespace Elsa.Workflows.Management.Enums;
/// <summary>
/// Specifies the operators for filtering timestamps in a TimestampFilter object.
/// </summary>
public enum TimestampFilterOperator
{
/// <summary>
/// The timestamp is equal to the specified value.
/// </summary>
Is,
/// <summary>
/// The timestamp is not equal to the specified value.
/// </summary>
IsNot,
/// <summary>
/// The timestamp is before the specified value.
/// </summary>
LessThan,
/// <summary>
/// The timestamp is greater than the specified value.
/// </summary>
GreaterThan,
/// <summary>
/// The timestamp is less than or equal to the specified value.
/// </summary>
LessThanOrEqual,
/// <summary>
/// The timestamp is greater than or equal to the specified value.
/// </summary>
GreaterThanOrEqual
}

View file

@ -1,4 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Enums;
using Elsa.Workflows.Management.Models;
using System.Linq.Dynamic.Core;
namespace Elsa.Workflows.Management.Filters;
@ -61,7 +65,7 @@ public class WorkflowInstanceFilter
/// Filter workflow instances by status.
/// </summary>
public WorkflowStatus? WorkflowStatus { get; set; }
/// <summary>
/// Filter workflow instances by a set of statuses.
/// </summary>
@ -71,25 +75,26 @@ public class WorkflowInstanceFilter
/// Filter workflow instances by sub-status.
/// </summary>
public WorkflowSubStatus? WorkflowSubStatus { get; set; }
/// <summary>
/// Filter workflow instances by a set of sub-status.
/// </summary>
public ICollection<WorkflowSubStatus>? WorkflowSubStatuses { get; set; }
/// <summary>
/// Filter workflow instances by created date time, start from this date time.
/// Filter workflow instances by whether they have incidents.
/// </summary>
public DateTimeOffset? FromCreatedAt { get; set; }
public bool? HasIncidents { get; set; }
/// <summary>
/// Filter workflow instances by created date time, to this date time.
/// Filter workflow instances by timestamp.
/// </summary>
public DateTimeOffset? ToCreatedAt { get; set; }
public ICollection<TimestampFilter>? TimestampFilters { get; set; }
/// <summary>
/// Applies the filter to the specified query.
/// </summary>
[RequiresUnreferencedCode("The method uses reflection to create an expression tree.")]
public IQueryable<WorkflowInstance> Apply(IQueryable<WorkflowInstance> query)
{
var filter = this;
@ -106,8 +111,36 @@ public class WorkflowInstanceFilter
if (filter.WorkflowSubStatus != null) query = query.Where(x => x.SubStatus == filter.WorkflowSubStatus);
if (filter.WorkflowStatuses != null) query = query.Where(x => filter.WorkflowStatuses.Contains(x.Status));
if (filter.WorkflowSubStatuses != null) query = query.Where(x => filter.WorkflowSubStatuses.Contains(x.SubStatus));
if (filter.ToCreatedAt is not null) query = query.Where(x => filter.ToCreatedAt >= x.CreatedAt);
if (filter.FromCreatedAt is not null) query = query.Where(x => filter.FromCreatedAt <= x.CreatedAt);
if (filter.HasIncidents != null) query = filter.HasIncidents == true ? query.Where(x => x.IncidentCount > 0) : query.Where(x => x.IncidentCount == 0);
if (TimestampFilters != null)
{
foreach (TimestampFilter timestampFilter in TimestampFilters)
{
var column = timestampFilter.Column;
var timestamp = timestampFilter.Timestamp;
var isZeroTime = timestamp.TimeOfDay == TimeSpan.Zero;
var startDay = new DateTimeOffset(timestamp.Date);
var endDay = startDay.AddDays(1);
query = timestampFilter.Operator switch
{
TimestampFilterOperator.Is when isZeroTime => query.Where($"{column} >= @0 && {column} < @1", startDay, endDay),
TimestampFilterOperator.Is => query.Where($"{column} == @0", timestamp),
TimestampFilterOperator.IsNot when isZeroTime => query.Where($"{column} < @0 || {column} >= @1", startDay, endDay),
TimestampFilterOperator.IsNot => query.Where($"{column} != @0", timestamp),
TimestampFilterOperator.GreaterThan when isZeroTime => query.Where($"{column} > @0", endDay),
TimestampFilterOperator.GreaterThan => query.Where($"{column} > @0", timestamp),
TimestampFilterOperator.GreaterThanOrEqual when isZeroTime => query.Where($"{column} >= @0", startDay),
TimestampFilterOperator.GreaterThanOrEqual => query.Where($"{column} >= @0", timestamp),
TimestampFilterOperator.LessThan when isZeroTime => query.Where($"{column} < @0", startDay),
TimestampFilterOperator.LessThan => query.Where($"{column} < @0", timestamp),
TimestampFilterOperator.LessThanOrEqual when isZeroTime => query.Where($"{column} <= @0", endDay),
TimestampFilterOperator.LessThanOrEqual => query.Where($"{column} <= @0", timestamp),
_ => query
};
}
}
var searchTerm = filter.SearchTerm;
if (!string.IsNullOrWhiteSpace(searchTerm))
@ -115,10 +148,10 @@ public class WorkflowInstanceFilter
query =
from instance in query
where instance.Name!.Contains(searchTerm)
|| instance.DefinitionVersionId.Equals(searchTerm)
|| instance.Id.Equals(searchTerm)
|| instance.DefinitionId.Equals(searchTerm)
|| instance.CorrelationId!.Equals(searchTerm)
|| instance.DefinitionVersionId.Contains(searchTerm)
|| instance.DefinitionId.Contains(searchTerm)
|| instance.Id.Contains(searchTerm)
|| instance.CorrelationId!.Contains(searchTerm)
select instance;
}

View file

@ -0,0 +1,26 @@
using Elsa.Workflows.Management.Enums;
using JetBrains.Annotations;
namespace Elsa.Workflows.Management.Models;
/// <summary>
/// Represents a timestamp filter used for filtering data based on a specified timestamp column and operator.
/// </summary>
[UsedImplicitly]
public class TimestampFilter
{
/// <summary>
/// Gets or sets the column to filter by.
/// </summary>
public required string Column { get; set; } = default!;
/// <summary>
/// Gets or sets the operator to use for filtering.
/// </summary>
public required TimestampFilterOperator Operator { get; set; }
/// <summary>
/// Gets or sets the timestamp to filter by.
/// </summary>
public required DateTimeOffset Timestamp { get; set; }
}

View file

@ -0,0 +1,8 @@
// See: https://github.com/dotnet/core/issues/8016
namespace System.Runtime.CompilerServices;
public class RequiredMemberAttribute : Attribute { }
public class CompilerFeatureRequiredAttribute : Attribute
{
public CompilerFeatureRequiredAttribute(string name) { }
}