From 99370502dcb5ae2e3336cf2fe025ae8150802110 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 13 Jun 2025 19:43:46 +0200 Subject: [PATCH 01/17] Refactor query composition to ensure proper ordering before pagination in `WorkflowExecutionLogStore`. --- .../Modules/Runtime/WorkflowExecutionLogStore.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs index 2569f479e..94ca16dc5 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs @@ -52,8 +52,8 @@ public class EFCoreWorkflowExecutionLogStore(EntityStore public async Task> FindManyAsync(WorkflowExecutionLogRecordFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default) { - var count = await store.QueryAsync(queryable => Filter(queryable, filter).OrderBy(x => x.Timestamp), cancellationToken).LongCount(); - var results = await store.QueryAsync(queryable => Filter(queryable, filter).Paginate(pageArgs), OnLoadAsync, cancellationToken).ToList(); + var count = await store.QueryAsync(queryable => Filter(queryable, filter), cancellationToken).LongCount(); + var results = await store.QueryAsync(queryable => Filter(queryable, filter).OrderBy(x => x.Timestamp).Paginate(pageArgs), OnLoadAsync, cancellationToken).ToList(); return new(results, count); } From 607dc73e67607389e0d691ce18c6a8532122ae27 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 13 Jun 2025 19:44:10 +0200 Subject: [PATCH 02/17] Fix ordering and pagination logic in `FindManyAsync` method for `WorkflowExecutionLogStore` Reordered query operations to ensure consistent execution of `OrderBy` before `Paginate`, improving clarity and maintaining the expected query behavior. --- .../Modules/Runtime/WorkflowExecutionLogStore.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs index 2569f479e..94ca16dc5 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs @@ -52,8 +52,8 @@ public class EFCoreWorkflowExecutionLogStore(EntityStore public async Task> FindManyAsync(WorkflowExecutionLogRecordFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default) { - var count = await store.QueryAsync(queryable => Filter(queryable, filter).OrderBy(x => x.Timestamp), cancellationToken).LongCount(); - var results = await store.QueryAsync(queryable => Filter(queryable, filter).Paginate(pageArgs), OnLoadAsync, cancellationToken).ToList(); + var count = await store.QueryAsync(queryable => Filter(queryable, filter), cancellationToken).LongCount(); + var results = await store.QueryAsync(queryable => Filter(queryable, filter).OrderBy(x => x.Timestamp).Paginate(pageArgs), OnLoadAsync, cancellationToken).ToList(); return new(results, count); } From b860370d9d6320805f13514fc9188ba042bf0954 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 13 Jun 2025 20:35:19 +0200 Subject: [PATCH 03/17] Add `HealActivity` alteration and handler with workflow fault recovery support - Introduced `HealActivity` alteration type for resolving faulted activity states. - Added `HealActivityHandler` to manage execution of `HealActivity`. - Enhanced `WorkflowExecutionContextExtensions` with `FindActivityExecutionContexts` method for locating activity execution contexts based on provided handles. - Updated `AlterationHandlerContext` to enable custom commit actions during `Succeed` calls. - Registered `HealActivity` and its handler in alteration services. - Upgraded `ElsaStudioVersion` to `3.5.0-preview.1092`. --- Directory.Packages.props | 2 +- src/apps/Directory.Build.props | 4 -- .../Contexts/AlterationHandlerContext.cs | 30 ++++++++++++- .../AlterationHandlers/HealActivityHandler.cs | 42 +++++++++++++++++++ .../AlterationTypes/HealActivity.cs | 17 ++++++++ .../Extensions/ServiceCollectionExtensions.cs | 1 + .../WorkflowExecutionContextExtensions.cs | 23 ++++++++-- .../Models/ActivityHandle.cs | 35 ++++++++++++---- 8 files changed, 137 insertions(+), 17 deletions(-) create mode 100644 src/modules/Elsa.Alterations/AlterationHandlers/HealActivityHandler.cs create mode 100644 src/modules/Elsa.Alterations/AlterationTypes/HealActivity.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 58bd46230..a0b5e6b20 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ true - 3.4.0 + 3.5.0-preview.1092 diff --git a/src/apps/Directory.Build.props b/src/apps/Directory.Build.props index 9edfcd282..45f95be6a 100644 --- a/src/apps/Directory.Build.props +++ b/src/apps/Directory.Build.props @@ -7,10 +7,6 @@ $(NoWarn);CS0162;CS1591 - - 3.5.0-preview.1040 - - diff --git a/src/modules/Elsa.Alterations.Core/Contexts/AlterationHandlerContext.cs b/src/modules/Elsa.Alterations.Core/Contexts/AlterationHandlerContext.cs index f583f6cd3..9836749e8 100644 --- a/src/modules/Elsa.Alterations.Core/Contexts/AlterationHandlerContext.cs +++ b/src/modules/Elsa.Alterations.Core/Contexts/AlterationHandlerContext.cs @@ -99,13 +99,26 @@ public class AlterationContext CommitAction = commitAction; } + /// + /// Marks the alteration as succeeded. + /// + public void Succeed(Action commitAction) + { + Succeed(); + CommitAction = () => + { + commitAction(); + return Task.CompletedTask; + }; + } + /// /// Marks the alteration as succeeded. /// public void Succeed(string message) { HasSucceeded = true; - Log($"Alteration {Alteration.GetType().Name} succeeded", message, LogLevel.Information); + Log($"Alteration {Alteration.GetType().Name} succeeded", message); } /// @@ -116,12 +129,25 @@ public class AlterationContext Succeed(message); CommitAction = commitAction; } + + /// + /// Marks the alteration as succeeded. + /// + public void Succeed(string message, Action commitAction) + { + Succeed(message); + CommitAction = () => + { + commitAction(); + return Task.CompletedTask; + }; + } /// /// Marks the alteration as failed. /// /// An optional message. - public void Fail(string? message = default) + public void Fail(string? message = null) { HasFailed = true; Log($"Alteration {Alteration.GetType().Name} failed", message ?? $"{Alteration.GetType().Name} failed", LogLevel.Error); diff --git a/src/modules/Elsa.Alterations/AlterationHandlers/HealActivityHandler.cs b/src/modules/Elsa.Alterations/AlterationHandlers/HealActivityHandler.cs new file mode 100644 index 000000000..715380a3d --- /dev/null +++ b/src/modules/Elsa.Alterations/AlterationHandlers/HealActivityHandler.cs @@ -0,0 +1,42 @@ +using Elsa.Alterations.AlterationTypes; +using Elsa.Alterations.Core.Abstractions; +using Elsa.Alterations.Core.Contexts; +using Elsa.Extensions; +using Elsa.Workflows; +using JetBrains.Annotations; + +namespace Elsa.Alterations.AlterationHandlers; + +/// +/// Cancels an activity. +/// +[UsedImplicitly] +public class HealActivityHandler : AlterationHandlerBase +{ + /// + protected override ValueTask HandleAsync(AlterationContext context, HealActivity alteration) + { + var activityExecutionContexts = context.WorkflowExecutionContext.FindActivityExecutionContexts(alteration.ActivityHandle).ToList(); + + if (!activityExecutionContexts.Any()) + { + context.Fail($"Activity execution context with handle {alteration.ActivityHandle} not found"); + + return ValueTask.CompletedTask; + } + + context.Succeed(() => Heal(activityExecutionContexts)); + return ValueTask.CompletedTask; + } + + private void Heal(IEnumerable activityExecutionContexts) + { + foreach (var activityExecutionContext in activityExecutionContexts) + Heal(activityExecutionContext); + } + + private void Heal(ActivityExecutionContext activityExecutionContext) + { + activityExecutionContext.RecoverFromFault(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Alterations/AlterationTypes/HealActivity.cs b/src/modules/Elsa.Alterations/AlterationTypes/HealActivity.cs new file mode 100644 index 000000000..1afbdb8d0 --- /dev/null +++ b/src/modules/Elsa.Alterations/AlterationTypes/HealActivity.cs @@ -0,0 +1,17 @@ +using Elsa.Alterations.Core.Abstractions; +using Elsa.Workflows.Models; +using JetBrains.Annotations; + +namespace Elsa.Alterations.AlterationTypes; + +/// +/// Heals an activity from the Faulted state. +/// +[UsedImplicitly] +public class HealActivity : AlterationBase +{ + /// + /// The handle to the to be healed. + /// + public ActivityHandle ActivityHandle { get; set; } = null!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs index b930d2878..5f2c069f1 100644 --- a/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs @@ -20,6 +20,7 @@ public static class ServiceCollectionExtensions services.AddAlteration(); services.AddAlteration(); services.AddAlteration(); + services.AddAlteration(); services.AddAlteration(); services.AddNotificationHandlersFrom(); return services; diff --git a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs index 57c01a6e6..1f683c0e6 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs @@ -50,8 +50,8 @@ public static class WorkflowExecutionContextExtensions public static ActivityWorkItem ScheduleActivityExecutionContext(this WorkflowExecutionContext workflowExecutionContext, ActivityExecutionContext activityExecutionContext, IDictionary? input = null, IEnumerable? variables = null) { var workItem = new ActivityWorkItem( - activityExecutionContext.Activity, - input: input, + activityExecutionContext.Activity, + input: input, variables: variables, existingActivityExecutionContext: activityExecutionContext); workflowExecutionContext.Scheduler.Schedule(workItem); @@ -112,7 +112,7 @@ public static class WorkflowExecutionContextExtensions // Validate that the specified activity is part of the workflow. if (!workflowExecutionContext.NodeActivityLookup.ContainsKey(activityNode.Activity)) throw new InvalidOperationException("The specified activity is not part of the workflow."); - + var scheduler = workflowExecutionContext.Scheduler; if (options?.PreventDuplicateScheduling == true) @@ -145,4 +145,21 @@ public static class WorkflowExecutionContextExtensions var outputRegister = workflowExecutionContext.GetActivityOutputRegister(); return outputRegister.FindOutputByActivityId(activityId, outputName); } + + public static IEnumerable FindActivityExecutionContexts(this WorkflowExecutionContext workflowExecutionContext, ActivityHandle activityHandle) + { + if (activityHandle.ActivityInstanceId != null) + return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Id == activityHandle.ActivityId); + if (activityHandle.ActivityNodeId != null) + return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.NodeId == activityHandle.ActivityNodeId); + if (activityHandle.ActivityId != null) + return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Activity.Id == activityHandle.ActivityId); + if (activityHandle.ActivityHash != null) + { + var activity = workflowExecutionContext.FindActivityByHash(activityHandle.ActivityHash); + return activity != null ? workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Activity.NodeId == activity.NodeId) : []; + } + + return []; + } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Models/ActivityHandle.cs b/src/modules/Elsa.Workflows.Core/Models/ActivityHandle.cs index ba0cb92c8..7ffefa1d2 100644 --- a/src/modules/Elsa.Workflows.Core/Models/ActivityHandle.cs +++ b/src/modules/Elsa.Workflows.Core/Models/ActivityHandle.cs @@ -5,12 +5,33 @@ namespace Elsa.Workflows.Models; /// public class ActivityHandle { - public static ActivityHandle FromActivityId(string activityId) => new() { ActivityId = activityId }; - public static ActivityHandle FromActivityNodeId(string activityNodeId) => new() { ActivityNodeId = activityNodeId }; - public static ActivityHandle FromActivityInstanceId(string activityInstanceId) => new() { ActivityInstanceId = activityInstanceId }; - public static ActivityHandle FromActivityHash(string activityHash) => new() { ActivityHash = activityHash }; + public static ActivityHandle FromActivityId(string activityId) => new() + { + ActivityId = activityId + }; + + public static ActivityHandle FromActivityNodeId(string activityNodeId) => new() + { + ActivityNodeId = activityNodeId + }; + + public static ActivityHandle FromActivityInstanceId(string activityInstanceId) => new() + { + ActivityInstanceId = activityInstanceId + }; + + public static ActivityHandle FromActivityHash(string activityHash) => new() + { + ActivityHash = activityHash + }; + public string? ActivityId { get; init; } - public string? ActivityNodeId { get; init;} - public string? ActivityInstanceId { get; init;} - public string? ActivityHash { get; init;} + public string? ActivityNodeId { get; init; } + public string? ActivityInstanceId { get; init; } + public string? ActivityHash { get; init; } + + public override string ToString() + { + return ActivityId ?? (ActivityNodeId ?? (ActivityInstanceId ?? (ActivityHash ?? ""))); + } } \ No newline at end of file From ce3a0b9ce0893fbb3598506c9aa44496f5a931dc Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 14 Jun 2025 11:38:25 +0200 Subject: [PATCH 04/17] Refactors alteration job dispatcher. Refactors the background alteration job dispatcher to use a service scope to resolve the alteration job runner. This ensures that the runner is resolved within a scope, allowing it to utilize scoped services. --- .../BackgroundAlterationJobDispatcher.cs | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/modules/Elsa.Alterations/Services/BackgroundAlterationJobDispatcher.cs b/src/modules/Elsa.Alterations/Services/BackgroundAlterationJobDispatcher.cs index 65e3b9ace..80874dc32 100644 --- a/src/modules/Elsa.Alterations/Services/BackgroundAlterationJobDispatcher.cs +++ b/src/modules/Elsa.Alterations/Services/BackgroundAlterationJobDispatcher.cs @@ -1,34 +1,25 @@ using Elsa.Alterations.Core.Contracts; using Elsa.Mediator.Contracts; +using Microsoft.Extensions.DependencyInjection; namespace Elsa.Alterations.Services; /// /// Dispatches an alteration job for execution using an in-memory channel. /// -public class BackgroundAlterationJobDispatcher : IAlterationJobDispatcher +public class BackgroundAlterationJobDispatcher(IJobQueue jobQueue, IServiceScopeFactory scopeFactory) : IAlterationJobDispatcher { - private readonly IJobQueue _jobQueue; - private readonly IAlterationJobRunner _alterationJobRunner; - - /// - /// Initializes a new instance of the class. - /// - public BackgroundAlterationJobDispatcher(IJobQueue jobQueue, IAlterationJobRunner alterationJobRunner) - { - _jobQueue = jobQueue; - _alterationJobRunner = alterationJobRunner; - } - /// public ValueTask DispatchAsync(string jobId, CancellationToken cancellationToken = default) { - _jobQueue.Enqueue(ct => ExecuteJobAsync(jobId, ct)); + jobQueue.Enqueue(ct => ExecuteJobAsync(jobId, ct)); return default; } private async Task ExecuteJobAsync(string alterationJobId, CancellationToken cancellationToken) { - await _alterationJobRunner.RunAsync(alterationJobId, cancellationToken); + using var scope = scopeFactory.CreateScope(); + var alterationJobRunner = scope.ServiceProvider.GetRequiredService(); + await alterationJobRunner.RunAsync(alterationJobId, cancellationToken); } } \ No newline at end of file From 2cbcefee836acd2c1b805be82543eb69f9acfa4f Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 14 Jun 2025 12:15:34 +0200 Subject: [PATCH 05/17] Remove `HealActivity` alteration and handler - Deleted `HealActivity` alteration type and associated `HealActivityHandler`. - Unregistered `HealActivity` from alteration services. --- .../AlterationHandlers/HealActivityHandler.cs | 42 ------------------- .../AlterationTypes/HealActivity.cs | 17 -------- .../Extensions/ServiceCollectionExtensions.cs | 1 - 3 files changed, 60 deletions(-) delete mode 100644 src/modules/Elsa.Alterations/AlterationHandlers/HealActivityHandler.cs delete mode 100644 src/modules/Elsa.Alterations/AlterationTypes/HealActivity.cs diff --git a/src/modules/Elsa.Alterations/AlterationHandlers/HealActivityHandler.cs b/src/modules/Elsa.Alterations/AlterationHandlers/HealActivityHandler.cs deleted file mode 100644 index 715380a3d..000000000 --- a/src/modules/Elsa.Alterations/AlterationHandlers/HealActivityHandler.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Elsa.Alterations.AlterationTypes; -using Elsa.Alterations.Core.Abstractions; -using Elsa.Alterations.Core.Contexts; -using Elsa.Extensions; -using Elsa.Workflows; -using JetBrains.Annotations; - -namespace Elsa.Alterations.AlterationHandlers; - -/// -/// Cancels an activity. -/// -[UsedImplicitly] -public class HealActivityHandler : AlterationHandlerBase -{ - /// - protected override ValueTask HandleAsync(AlterationContext context, HealActivity alteration) - { - var activityExecutionContexts = context.WorkflowExecutionContext.FindActivityExecutionContexts(alteration.ActivityHandle).ToList(); - - if (!activityExecutionContexts.Any()) - { - context.Fail($"Activity execution context with handle {alteration.ActivityHandle} not found"); - - return ValueTask.CompletedTask; - } - - context.Succeed(() => Heal(activityExecutionContexts)); - return ValueTask.CompletedTask; - } - - private void Heal(IEnumerable activityExecutionContexts) - { - foreach (var activityExecutionContext in activityExecutionContexts) - Heal(activityExecutionContext); - } - - private void Heal(ActivityExecutionContext activityExecutionContext) - { - activityExecutionContext.RecoverFromFault(); - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Alterations/AlterationTypes/HealActivity.cs b/src/modules/Elsa.Alterations/AlterationTypes/HealActivity.cs deleted file mode 100644 index 1afbdb8d0..000000000 --- a/src/modules/Elsa.Alterations/AlterationTypes/HealActivity.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Elsa.Alterations.Core.Abstractions; -using Elsa.Workflows.Models; -using JetBrains.Annotations; - -namespace Elsa.Alterations.AlterationTypes; - -/// -/// Heals an activity from the Faulted state. -/// -[UsedImplicitly] -public class HealActivity : AlterationBase -{ - /// - /// The handle to the to be healed. - /// - public ActivityHandle ActivityHandle { get; set; } = null!; -} \ No newline at end of file diff --git a/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs b/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs index 5f2c069f1..b930d2878 100644 --- a/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs +++ b/src/modules/Elsa.Alterations/Extensions/ServiceCollectionExtensions.cs @@ -20,7 +20,6 @@ public static class ServiceCollectionExtensions services.AddAlteration(); services.AddAlteration(); services.AddAlteration(); - services.AddAlteration(); services.AddAlteration(); services.AddNotificationHandlersFrom(); return services; From 54fcd9305b07dc32b8e4f1ba9480b8156f4e4630 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 14 Jun 2025 12:15:46 +0200 Subject: [PATCH 06/17] Simplify exception handling logic in `ExceptionHandlingMiddleware` - Removed `LogExceptionAndTransition` and `FaultAncestors` methods. - Integrated exception fault handling with `ActivityExecutionContext.Fault()`. --- .../Activities/ExceptionHandlingMiddleware.cs | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs index 60f4e3fec..b7028e2b8 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs @@ -1,8 +1,6 @@ using Elsa.Common; using Elsa.Extensions; -using Elsa.Workflows.Models; using Elsa.Workflows.Pipelines.ActivityExecution; -using Elsa.Workflows.State; using Microsoft.Extensions.Logging; namespace Elsa.Workflows.Middleware.Activities; @@ -34,34 +32,14 @@ public class ExceptionHandlingMiddleware(ActivityMiddlewareDelegate next, IIncid catch (Exception e) { logger.LogWarning(e, "An exception was caught from a downstream middleware component"); - LogExceptionAndTransition(context, e); - FaultAncestors(context); + context.Fault(e); await HandleIncidentAsync(context); } } - private void LogExceptionAndTransition(ActivityExecutionContext context, Exception e) - { - context.Exception = e; - context.TransitionTo(ActivityStatus.Faulted); - var activity = context.Activity; - var exceptionState = ExceptionState.FromException(e); - var now = systemClock.UtcNow; - var incident = new ActivityIncident(activity.Id, activity.NodeId ,activity.Type, e.Message, exceptionState, now); - context.WorkflowExecutionContext.Incidents.Add(incident); - } - private async Task HandleIncidentAsync(ActivityExecutionContext context) { var strategy = await incidentStrategyResolver.ResolveStrategyAsync(context); strategy.HandleIncident(context); } - - private static void FaultAncestors(ActivityExecutionContext context) - { - var ancestors = context.GetAncestors(); - - foreach (var ancestor in ancestors) - ancestor.TransitionTo(ActivityStatus.Faulted); - } } \ No newline at end of file From ab6bb23df9cc92e7f467d7b66f0cf8735dd321d1 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 14 Jun 2025 13:00:45 +0200 Subject: [PATCH 07/17] Update src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Extensions/WorkflowExecutionContextExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs index 1f683c0e6..f025ef74d 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs @@ -149,7 +149,7 @@ public static class WorkflowExecutionContextExtensions public static IEnumerable FindActivityExecutionContexts(this WorkflowExecutionContext workflowExecutionContext, ActivityHandle activityHandle) { if (activityHandle.ActivityInstanceId != null) - return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Id == activityHandle.ActivityId); + return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.Id == activityHandle.ActivityInstanceId); if (activityHandle.ActivityNodeId != null) return workflowExecutionContext.ActivityExecutionContexts.Where(x => x.NodeId == activityHandle.ActivityNodeId); if (activityHandle.ActivityId != null) From 776630a556c286fb0615b0ddae03669dd61c3947 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 5 Jun 2025 00:36:18 +0100 Subject: [PATCH 08/17] Create Api Client Models for upcoming RadioList UIHint. --- src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj | 4 ++-- .../Shared/UIHints/RadioList/RadioList.cs | 3 +++ .../Shared/UIHints/RadioList/RadioListItem.cs | 9 +++++++++ .../Shared/UIHints/RadioList/RadioListProps.cs | 9 +++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioList.cs create mode 100644 src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListItem.cs create mode 100644 src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs diff --git a/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj b/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj index f8246f150..068b8badc 100644 --- a/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj +++ b/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj @@ -1,4 +1,4 @@ - + @@ -17,4 +17,4 @@ - + \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioList.cs b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioList.cs new file mode 100644 index 000000000..3075e7be8 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioList.cs @@ -0,0 +1,3 @@ +namespace Elsa.Api.Client.Shared.UIHints.RadioList; + +public record RadioList(IEnumerable Items, bool IsFlagsEnum = false); \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListItem.cs b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListItem.cs new file mode 100644 index 000000000..45ba5d2e2 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListItem.cs @@ -0,0 +1,9 @@ +namespace Elsa.Api.Client.Shared.UIHints.RadioList; + +public class RadioListItem +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public string Text { get; set; } + public string Value { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs new file mode 100644 index 000000000..fa1c6a25d --- /dev/null +++ b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs @@ -0,0 +1,9 @@ +namespace Elsa.Api.Client.Shared.UIHints.RadioList; + +public class RadioListProps +{ + /// + /// The select list. + /// + public RadioList? CheckList { get; set; } +} \ No newline at end of file From b4fd26d52376037e89a424d140251d99947f2aca Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 5 Jun 2025 01:56:44 +0100 Subject: [PATCH 09/17] Add radio list support and related UI components - Renamed `CheckList` to `RadioList` in `RadioListProps`. - Updated `WorkflowsFeature` to include `RadioListUIHintHandler` and `StaticRadioListOptionsProvider`. - Introduced `TestRadioList` class for executing radio list functionality. - Created `RadioList` and `RadioListItem` classes for managing radio list items. - Added `RadioListOptionsProviderBase` for custom radio list data logic. - Implemented `StaticRadioListOptionsProvider` for static radio list options. --- src/apps/Elsa.Server.Web/RadioListActivity.cs | 42 +++++++++++++++++ .../UIHints/RadioList/RadioListProps.cs | 2 +- .../Features/WorkflowsFeature.cs | 3 ++ .../UIHints/RadioList/RadioList.cs | 7 +++ .../UIHints/RadioList/RadioListItem.cs | 3 ++ .../RadioList/RadioListOptionsProviderBase.cs | 47 +++++++++++++++++++ .../UIHints/RadioList/RadioListProps.cs | 9 ++++ .../RadioList/RadioListUIHintHandler.cs | 16 +++++++ .../StaticRadioListOptionsProvider.cs | 29 ++++++++++++ 9 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/apps/Elsa.Server.Web/RadioListActivity.cs create mode 100644 src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs create mode 100644 src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListItem.cs create mode 100644 src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListOptionsProviderBase.cs create mode 100644 src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListProps.cs create mode 100644 src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListUIHintHandler.cs create mode 100644 src/modules/Elsa.Workflows.Core/UIHints/RadioList/StaticRadioListOptionsProvider.cs diff --git a/src/apps/Elsa.Server.Web/RadioListActivity.cs b/src/apps/Elsa.Server.Web/RadioListActivity.cs new file mode 100644 index 000000000..2e7a38208 --- /dev/null +++ b/src/apps/Elsa.Server.Web/RadioListActivity.cs @@ -0,0 +1,42 @@ +using System.Runtime.CompilerServices; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.UIHints; +using Elsa.Workflows.Models; + +// ReSharper disable once CheckNamespace +namespace Elsa.Server.Web; + +/// +/// Executes C# code. +/// +[Activity("Elsa", "TESTS", "Tests Radio List Functionality", DisplayName = "TEST")] +public class TestRadioList : CodeActivity +{ + /// + public TestRadioList([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) + { + } + + /// + public TestRadioList(string script, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : this(source, line) + { + } + + /// + /// The script to run. + /// + [Input( + Description = "Choose to download one file or entire folder", + DefaultValue = "File", + Options = new[] { "File", "Folder" }, + UIHint = InputUIHints.RadioList + )] + public Input SelectedRadioOption { get; set; } = default!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + + } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs index fa1c6a25d..a4441a15f 100644 --- a/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs +++ b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs @@ -5,5 +5,5 @@ public class RadioListProps /// /// The select list. /// - public RadioList? CheckList { get; set; } + public RadioList? RadioList { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs index 5759abed5..cc3f81aad 100644 --- a/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs +++ b/src/modules/Elsa.Workflows.Core/Features/WorkflowsFeature.cs @@ -24,6 +24,7 @@ using Elsa.Workflows.Services; using Elsa.Workflows.UIHints.CheckList; using Elsa.Workflows.UIHints.Dropdown; using Elsa.Workflows.UIHints.JsonEditor; +using Elsa.Workflows.UIHints.RadioList; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Workflows.Features; @@ -230,10 +231,12 @@ public class WorkflowsFeature : FeatureBase // UI hints. .AddScoped() .AddScoped() + .AddScoped() .AddScoped() // UI property handlers. .AddScoped() + .AddScoped() .AddScoped() .AddScoped() diff --git a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs new file mode 100644 index 000000000..0dd5e25ec --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs @@ -0,0 +1,7 @@ +namespace Elsa.Workflows.UIHints.RadioList; + +public class RadioList +{ + public IEnumerable Items { get; set; } + public bool IsFlagsEnum { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListItem.cs b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListItem.cs new file mode 100644 index 000000000..d683b8070 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListItem.cs @@ -0,0 +1,3 @@ +namespace Elsa.Workflows.UIHints.RadioList; + +public record RadioListItem(string Text, string Value); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListOptionsProviderBase.cs b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListOptionsProviderBase.cs new file mode 100644 index 000000000..aa09266cb --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListOptionsProviderBase.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using Elsa.Extensions; + +namespace Elsa.Workflows.UIHints.RadioList; + +/// +/// A base class for providing options to populate a checklist UI component. This class is intended to be inherited to implement +/// custom radiolist data logic by overriding the `GetItemsAsync` method. +/// +public abstract class RadioListOptionsProviderBase : PropertyUIHandlerBase +{ + protected virtual bool RefreshOnChange => false; + + /// + public override async ValueTask> GetUIPropertiesAsync(PropertyInfo propertyInfo, object? context, CancellationToken cancellationToken = default) + { + var items = await GetItemsAsync(propertyInfo, context, cancellationToken); + var props = new RadioListProps + { + RadioList = new() + { + Items = items.ToList() + } + }; + + var options = new Dictionary + { + [InputUIHints.RadioList] = props + }; + + options.AddRange(GetUIPropertyAdditionalOptions()); + + return options; + } + + /// + /// Implement this to provide items to the dropdown list. + /// + protected abstract ValueTask> GetItemsAsync(PropertyInfo propertyInfo, object? context, CancellationToken cancellationToken); + + protected virtual IDictionary GetUIPropertyAdditionalOptions() + { + var options = new Dictionary(); + if (RefreshOnChange) options["Refresh"] = true; + return options; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListProps.cs b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListProps.cs new file mode 100644 index 000000000..2465cdda5 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListProps.cs @@ -0,0 +1,9 @@ +namespace Elsa.Workflows.UIHints.RadioList; + +public class RadioListProps +{ + /// + /// The select list. + /// + public RadioList? RadioList { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListUIHintHandler.cs b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListUIHintHandler.cs new file mode 100644 index 000000000..48607267c --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListUIHintHandler.cs @@ -0,0 +1,16 @@ +using System.Reflection; + +namespace Elsa.Workflows.UIHints.RadioList; + +/// +public class RadioListUIHintHandler : IUIHintHandler +{ + /// + public string UIHint => InputUIHints.RadioList; + + /// + public ValueTask> GetPropertyUIHandlersAsync(PropertyInfo propertyInfo, CancellationToken cancellationToken) + { + return new([typeof(StaticRadioListOptionsProvider)]); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/StaticRadioListOptionsProvider.cs b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/StaticRadioListOptionsProvider.cs new file mode 100644 index 000000000..f05f31948 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/StaticRadioListOptionsProvider.cs @@ -0,0 +1,29 @@ +using System.Reflection; +using Elsa.Workflows.Attributes; + +namespace Elsa.Workflows.UIHints.RadioList; + +/// +/// Provides static drop-down options for a given property. +/// +public class StaticRadioListOptionsProvider : RadioListOptionsProviderBase +{ + public override float Priority => -1; + + /// + protected override ValueTask> GetItemsAsync(PropertyInfo propertyInfo, object? context, CancellationToken cancellationToken) + { + var inputAttribute = propertyInfo.GetCustomAttribute(); + var inputOptions = inputAttribute?.Options; + + if (inputOptions == null) + return new([]); + + var selectListItems = (inputOptions as ICollection)?.Select(x => new RadioListItem(x, x)).ToList(); + + if (selectListItems == null) + return new([]); + + return new(selectListItems); + } +} \ No newline at end of file From 4530dfd1f7c8303519ae84803f77a0c7334b95ce Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 6 Jun 2025 00:13:35 +0100 Subject: [PATCH 10/17] Refactor checklists and radio lists to use records Converted `CheckList` and `CheckListItem` to records, adding XML documentation for clarity. Updated properties in `CheckListProps`, `RadioList`, and `RadioListItem` with similar changes. Enhanced documentation in `DropDownOptionsProviderBase` and modified `RadioListOptionsProviderBase` to reflect new functionality. Overall improvements for readability and maintainability. --- .../Shared/UIHints/CheckList/CheckList.cs | 5 +++++ .../Shared/UIHints/CheckList/CheckListItem.cs | 12 ++++-------- .../Shared/UIHints/CheckList/CheckListProps.cs | 3 +++ .../Shared/UIHints/RadioList/RadioList.cs | 5 +++++ .../Shared/UIHints/RadioList/RadioListItem.cs | 11 ++++------- .../Shared/UIHints/RadioList/RadioListProps.cs | 3 +++ .../UIHints/CheckList/CheckList.cs | 10 ++++++++++ .../UIHints/CheckList/CheckListProps.cs | 3 +++ .../UIHints/Dropdown/DropDownOptionsProviderBase.cs | 3 ++- .../UIHints/RadioList/RadioList.cs | 10 ++++++++++ .../RadioList/RadioListOptionsProviderBase.cs | 2 +- .../UIHints/RadioList/RadioListProps.cs | 3 +++ 12 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckList.cs b/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckList.cs index 3caba2572..ed04df0e6 100644 --- a/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckList.cs +++ b/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckList.cs @@ -1,3 +1,8 @@ namespace Elsa.Api.Client.Shared.UIHints.CheckList; +/// +/// Represents a list of check list items. +/// +/// The items. +/// Whether the select list represents a flags enum. public record CheckList(IEnumerable Items, bool IsFlagsEnum = false); \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListItem.cs b/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListItem.cs index 53cd50bd6..62d5e6bf5 100644 --- a/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListItem.cs +++ b/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListItem.cs @@ -1,10 +1,6 @@ namespace Elsa.Api.Client.Shared.UIHints.CheckList; -public class CheckListItem -{ -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - public string Text { get; set; } - public string Value { get; set; } -#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - public bool IsChecked { get; set; } -} \ No newline at end of file +/// +/// Represents an item in a . +/// +public record CheckListItem(string Text, string Value, bool IsChecked); \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListProps.cs b/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListProps.cs index 431e9263d..4ef203dc7 100644 --- a/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListProps.cs +++ b/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListProps.cs @@ -1,5 +1,8 @@ namespace Elsa.Api.Client.Shared.UIHints.CheckList; +/// +/// Provides properties for the checklist UI hint. +/// public class CheckListProps { /// diff --git a/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioList.cs b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioList.cs index 3075e7be8..b17e00f1e 100644 --- a/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioList.cs +++ b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioList.cs @@ -1,3 +1,8 @@ namespace Elsa.Api.Client.Shared.UIHints.RadioList; +/// +/// Represents a list of radio list items. +/// +/// The items. +/// Whether the select list represents a flags enum. public record RadioList(IEnumerable Items, bool IsFlagsEnum = false); \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListItem.cs b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListItem.cs index 45ba5d2e2..3e7722287 100644 --- a/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListItem.cs +++ b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListItem.cs @@ -1,9 +1,6 @@ namespace Elsa.Api.Client.Shared.UIHints.RadioList; -public class RadioListItem -{ -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - public string Text { get; set; } - public string Value { get; set; } -#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. -} \ No newline at end of file +/// +/// Represents an item in a . +/// +public record RadioListItem(string Text, string Value); \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs index a4441a15f..be100b644 100644 --- a/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs +++ b/src/clients/Elsa.Api.Client/Shared/UIHints/RadioList/RadioListProps.cs @@ -1,5 +1,8 @@ namespace Elsa.Api.Client.Shared.UIHints.RadioList; +/// +/// Provides properties for the radiolist UI hint. +/// public class RadioListProps { /// diff --git a/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs b/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs index dcb697d7e..4380ecae1 100644 --- a/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs +++ b/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckList.cs @@ -1,7 +1,17 @@ namespace Elsa.Workflows.UIHints.CheckList; +/// +/// Provides properties for the UI hint. +/// public class CheckList { + /// + /// The radio list. + /// public IEnumerable Items { get; set; } + + /// + /// The name of the provider that will provide the select list. + /// public bool IsFlagsEnum { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckListProps.cs b/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckListProps.cs index 4d3d6e8df..f2dda59aa 100644 --- a/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckListProps.cs +++ b/src/modules/Elsa.Workflows.Core/UIHints/CheckList/CheckListProps.cs @@ -1,5 +1,8 @@ namespace Elsa.Workflows.UIHints.CheckList; +/// +/// Provides properties for the UI hint. +/// public class CheckListProps { /// diff --git a/src/modules/Elsa.Workflows.Core/UIHints/Dropdown/DropDownOptionsProviderBase.cs b/src/modules/Elsa.Workflows.Core/UIHints/Dropdown/DropDownOptionsProviderBase.cs index e248cb557..5ee12af51 100644 --- a/src/modules/Elsa.Workflows.Core/UIHints/Dropdown/DropDownOptionsProviderBase.cs +++ b/src/modules/Elsa.Workflows.Core/UIHints/Dropdown/DropDownOptionsProviderBase.cs @@ -4,7 +4,8 @@ using Elsa.Extensions; namespace Elsa.Workflows.UIHints.Dropdown; /// -/// +/// A base class for providing options to populate a dropdown UI component. This class is intended to be inherited to implement +/// custom dropdown data logic by overriding the `GetItemsAsync` method. /// public abstract class DropDownOptionsProviderBase : IPropertyUIHandler { diff --git a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs index 0dd5e25ec..ace833be9 100644 --- a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs +++ b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioList.cs @@ -1,7 +1,17 @@ namespace Elsa.Workflows.UIHints.RadioList; +/// +/// Provides properties for the UI hint. +/// public class RadioList { + /// + /// The radio list. + /// public IEnumerable Items { get; set; } + + /// + /// The name of the provider that will provide the select list. + /// public bool IsFlagsEnum { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListOptionsProviderBase.cs b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListOptionsProviderBase.cs index aa09266cb..70bae9a45 100644 --- a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListOptionsProviderBase.cs +++ b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListOptionsProviderBase.cs @@ -34,7 +34,7 @@ public abstract class RadioListOptionsProviderBase : PropertyUIHandlerBase } /// - /// Implement this to provide items to the dropdown list. + /// Implement this to provide items to the radio list. /// protected abstract ValueTask> GetItemsAsync(PropertyInfo propertyInfo, object? context, CancellationToken cancellationToken); diff --git a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListProps.cs b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListProps.cs index 2465cdda5..bdb15084a 100644 --- a/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListProps.cs +++ b/src/modules/Elsa.Workflows.Core/UIHints/RadioList/RadioListProps.cs @@ -1,5 +1,8 @@ namespace Elsa.Workflows.UIHints.RadioList; +/// +/// Provides properties for the UI hint. +/// public class RadioListProps { /// From 2dbd050e6472e156ff9175b52ab2239552559fad Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 6 Jun 2025 00:44:10 +0100 Subject: [PATCH 11/17] Revert ChecklistItem back from Record to Class and remove left over RadioListActivity. Update Nuget packages. --- src/apps/Elsa.Server.Web/RadioListActivity.cs | 42 ------------------- .../Shared/UIHints/CheckList/CheckListItem.cs | 9 +++- 2 files changed, 8 insertions(+), 43 deletions(-) delete mode 100644 src/apps/Elsa.Server.Web/RadioListActivity.cs diff --git a/src/apps/Elsa.Server.Web/RadioListActivity.cs b/src/apps/Elsa.Server.Web/RadioListActivity.cs deleted file mode 100644 index 2e7a38208..000000000 --- a/src/apps/Elsa.Server.Web/RadioListActivity.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Runtime.CompilerServices; -using Elsa.Workflows; -using Elsa.Workflows.Attributes; -using Elsa.Workflows.UIHints; -using Elsa.Workflows.Models; - -// ReSharper disable once CheckNamespace -namespace Elsa.Server.Web; - -/// -/// Executes C# code. -/// -[Activity("Elsa", "TESTS", "Tests Radio List Functionality", DisplayName = "TEST")] -public class TestRadioList : CodeActivity -{ - /// - public TestRadioList([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line) - { - } - - /// - public TestRadioList(string script, [CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : this(source, line) - { - } - - /// - /// The script to run. - /// - [Input( - Description = "Choose to download one file or entire folder", - DefaultValue = "File", - Options = new[] { "File", "Folder" }, - UIHint = InputUIHints.RadioList - )] - public Input SelectedRadioOption { get; set; } = default!; - - /// - protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) - { - - } -} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListItem.cs b/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListItem.cs index 62d5e6bf5..abeeee9a2 100644 --- a/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListItem.cs +++ b/src/clients/Elsa.Api.Client/Shared/UIHints/CheckList/CheckListItem.cs @@ -3,4 +3,11 @@ namespace Elsa.Api.Client.Shared.UIHints.CheckList; /// /// Represents an item in a . /// -public record CheckListItem(string Text, string Value, bool IsChecked); \ No newline at end of file +public class CheckListItem +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public string Text { get; set; } + public string Value { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public bool IsChecked { get; set; } +} \ No newline at end of file From c4451c4f40f843dcbba6b66e7c7a0b8ff45c9bae Mon Sep 17 00:00:00 2001 From: lukhipolito-nexxbiz Date: Mon, 30 Jun 2025 16:56:51 +0200 Subject: [PATCH 12/17] Feat/6732 zip archive activities (#6751) * Io module and content strategies * Restructuring the folders and projects * Create zip activity + shared content resolver * Fixing build on github.com * Update src/modules/Elsa.IO/Services/ContentResolver.cs explicit nullable field Co-authored-by: Sipke Schoorstra * QoL improvements * More QoL improvements + removing some logic duplications * Update src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs avoiding duplicate extensions Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Assigning file path correctly to the variable * More QoL changes, new Elsa.IO.Http module, more modular content strategy * Addresing QoL review comments + Correct extension handling --------- Co-authored-by: lucas.hipolito Co-authored-by: Sipke Schoorstra Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Elsa.sln | 53 +++++-- .../Elsa.ServerAndStudio.Web.csproj | 2 + src/apps/Elsa.ServerAndStudio.Web/Program.cs | 3 + ...Feature.cs => StringCompressionFeature.cs} | 2 +- .../Elsa.Http/Extensions/HeadersExtensions.cs | 2 + .../Activities/CreateZipArchive.cs | 140 ++++++++++++++++++ .../Elsa.IO.Compression/Common/Constants.cs | 6 + .../Elsa.IO.Compression.csproj | 23 +++ .../Extensions/ModuleExtensions.cs | 20 +++ .../Features/CompressionFeature.cs | 33 +++++ .../Elsa.IO.Compression/FodyWeavers.xml | 3 + .../Elsa.IO.Compression/Models/ZipEntry.cs | 11 ++ .../Strategies/ZipEntryContentStrategy.cs | 44 ++++++ src/modules/Elsa.IO.Http/Common/Constants.cs | 14 ++ src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj | 21 +++ .../Elsa.IO.Http/Features/IOHttpFeature.cs | 29 ++++ src/modules/Elsa.IO.Http/FodyWeavers.xml | 3 + .../Services/Strategies/UrlContentStrategy.cs | 78 ++++++++++ src/modules/Elsa.IO/Common/Constants.cs | 38 +++++ .../Elsa.IO/Contracts/IContentResolver.cs | 17 +++ src/modules/Elsa.IO/Elsa.IO.csproj | 21 +++ .../Extensions/ContentTypeExtensions.cs | 110 ++++++++++++++ .../Elsa.IO/Extensions/FilePathExtensions.cs | 18 +++ .../Elsa.IO/Extensions/ModuleExtensions.cs | 19 +++ src/modules/Elsa.IO/Features/IOFeature.cs | 26 ++++ src/modules/Elsa.IO/FodyWeavers.xml | 1 + src/modules/Elsa.IO/Models/BinaryContent.cs | 33 +++++ .../Elsa.IO/Services/ContentResolver.cs | 34 +++++ .../Strategies/Base64ContentStrategy.cs | 67 +++++++++ .../Strategies/ByteArrayContentStrategy.cs | 31 ++++ .../Strategies/FilePathContentStrategy.cs | 89 +++++++++++ .../Strategies/IContentResolverStrategy.cs | 29 ++++ .../Strategies/StreamContentStrategy.cs | 37 +++++ .../Strategies/TextContentStrategy.cs | 31 ++++ .../ConfigureEngineWithVariableTypes.cs | 24 +++ .../Features/WorkflowManagementFeature.cs | 2 +- 36 files changed, 1098 insertions(+), 16 deletions(-) rename src/modules/Elsa.Common/Features/{CompressionFeature.cs => StringCompressionFeature.cs} (87%) create mode 100644 src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs create mode 100644 src/modules/Elsa.IO.Compression/Common/Constants.cs create mode 100644 src/modules/Elsa.IO.Compression/Elsa.IO.Compression.csproj create mode 100644 src/modules/Elsa.IO.Compression/Extensions/ModuleExtensions.cs create mode 100644 src/modules/Elsa.IO.Compression/Features/CompressionFeature.cs create mode 100644 src/modules/Elsa.IO.Compression/FodyWeavers.xml create mode 100644 src/modules/Elsa.IO.Compression/Models/ZipEntry.cs create mode 100644 src/modules/Elsa.IO.Compression/Services/Strategies/ZipEntryContentStrategy.cs create mode 100644 src/modules/Elsa.IO.Http/Common/Constants.cs create mode 100644 src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj create mode 100644 src/modules/Elsa.IO.Http/Features/IOHttpFeature.cs create mode 100644 src/modules/Elsa.IO.Http/FodyWeavers.xml create mode 100644 src/modules/Elsa.IO.Http/Services/Strategies/UrlContentStrategy.cs create mode 100644 src/modules/Elsa.IO/Common/Constants.cs create mode 100644 src/modules/Elsa.IO/Contracts/IContentResolver.cs create mode 100644 src/modules/Elsa.IO/Elsa.IO.csproj create mode 100644 src/modules/Elsa.IO/Extensions/ContentTypeExtensions.cs create mode 100644 src/modules/Elsa.IO/Extensions/FilePathExtensions.cs create mode 100644 src/modules/Elsa.IO/Extensions/ModuleExtensions.cs create mode 100644 src/modules/Elsa.IO/Features/IOFeature.cs create mode 100644 src/modules/Elsa.IO/FodyWeavers.xml create mode 100644 src/modules/Elsa.IO/Models/BinaryContent.cs create mode 100644 src/modules/Elsa.IO/Services/ContentResolver.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/Base64ContentStrategy.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/ByteArrayContentStrategy.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/FilePathContentStrategy.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/IContentResolverStrategy.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/StreamContentStrategy.cs create mode 100644 src/modules/Elsa.IO/Services/Strategies/TextContentStrategy.cs create mode 100644 src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariableTypes.cs diff --git a/Elsa.sln b/Elsa.sln index 373517434..d171f43ce 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.7.34003.232 @@ -244,8 +245,8 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Resilience.IntegrationTests", "test\integration\Elsa.Resilience.IntegrationTests\Elsa.Resilience.IntegrationTests.csproj", "{832675FA-C597-4554-AE6B-18F189198A1F}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "apps", "apps", "{D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1}" - ProjectSection(SolutionItems) = preProject - src\apps\Directory.Build.props = src\apps\Directory.Build.props + ProjectSection(SolutionItems) = preProject + src\apps\Directory.Build.props = src\apps\Directory.Build.props EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Server.Web", "src\apps\Elsa.Server.Web\Elsa.Server.Web.csproj", "{5ADDDFB1-E59B-4097-97B7-8C24E2D60463}" @@ -405,6 +406,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience.Core", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Resilience", "src\modules\Elsa.Resilience\Elsa.Resilience.csproj", "{E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "io", "io", "{7FD1FD1E-5778-4065-AAA5-1F878129EF77}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO", "src\modules\Elsa.IO\Elsa.IO.csproj", "{EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO.Compression", "src\modules\Elsa.IO.Compression\Elsa.IO.Compression.csproj", "{9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.IO.Http", "src\modules\Elsa.IO.Http\Elsa.IO.Http.csproj", "{C583AF05-D517-4B7F-8955-6B61500ED3D8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -698,14 +707,14 @@ Global {99B171E6-0248-4402-836D-98947CD63772}.Release|Any CPU.ActiveCfg = Release|Any CPU {99B171E6-0248-4402-836D-98947CD63772}.Release|Any CPU.Build.0 = Release|Any CPU {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.Build.0 = Release|Any CPU - {832675FA-C597-4554-AE6B-18F189198A1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {832675FA-C597-4554-AE6B-18F189198A1F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {832675FA-C597-4554-AE6B-18F189198A1F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {832675FA-C597-4554-AE6B-18F189198A1F}.Release|Any CPU.Build.0 = Release|Any CPU - {5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D}.Release|Any CPU.Build.0 = Release|Any CPU + {832675FA-C597-4554-AE6B-18F189198A1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {832675FA-C597-4554-AE6B-18F189198A1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {832675FA-C597-4554-AE6B-18F189198A1F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {832675FA-C597-4554-AE6B-18F189198A1F}.Release|Any CPU.Build.0 = Release|Any CPU + {5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Debug|Any CPU.Build.0 = Debug|Any CPU {5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Release|Any CPU.ActiveCfg = Release|Any CPU {5ADDDFB1-E59B-4097-97B7-8C24E2D60463}.Release|Any CPU.Build.0 = Release|Any CPU @@ -841,6 +850,18 @@ Global {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Debug|Any CPU.Build.0 = Debug|Any CPU {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.ActiveCfg = Release|Any CPU {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6}.Release|Any CPU.Build.0 = Release|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5}.Release|Any CPU.Build.0 = Release|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9}.Release|Any CPU.Build.0 = Release|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C583AF05-D517-4B7F-8955-6B61500ED3D8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -935,10 +956,10 @@ Global {94A61AD7-2A2B-40DB-81F3-C59D596958A8} = {C6658DE0-2B2F-47F0-BB61-2CA66D435C09} {4B598AF7-BD7D-4544-A274-2CDDD98F4167} = {C6658DE0-2B2F-47F0-BB61-2CA66D435C09} {99B171E6-0248-4402-836D-98947CD63772} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} - {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} - {832675FA-C597-4554-AE6B-18F189198A1F} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} - {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} = {61017E64-6D00-49CB-9E81-5002DC8F7D5F} - {5ADDDFB1-E59B-4097-97B7-8C24E2D60463} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} + {4332A6BC-434A-4AF5-A075-F1BBCDD28F5D} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {832675FA-C597-4554-AE6B-18F189198A1F} = {1B8D5897-902E-4632-8698-E89CAF3DDF54} + {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} = {61017E64-6D00-49CB-9E81-5002DC8F7D5F} + {5ADDDFB1-E59B-4097-97B7-8C24E2D60463} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} {97C7E531-9D5F-43FD-AA19-BF24DA13B612} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {65F2AD97-3ECF-4BA6-8AAA-1E5882FDCE68} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} {690B0274-291F-4D9E-BA76-54EFF7D3E4BC} = {D92BEAB2-60D6-4BB4-885A-6BA681C6CCF1} @@ -990,6 +1011,10 @@ Global {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {70593549-8B26-4D63-9857-6BA8BB3E31DB} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} {E7137FB0-1988-4562-AD8D-D0D9D2EE85F6} = {CD7DC0D1-FFDC-417A-89BE-7F32408F583E} + {7FD1FD1E-5778-4065-AAA5-1F878129EF77} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {EB24F9FE-D7BD-4FCC-907E-AE400288C2A5} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} + {9CA02818-F7EB-4A0B-B27B-BC74ACD499C9} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} + {C583AF05-D517-4B7F-8955-6B61500ED3D8} = {7FD1FD1E-5778-4065-AAA5-1F878129EF77} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj b/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj index 3ea5601f3..707e9f6a5 100644 --- a/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj +++ b/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj @@ -14,6 +14,7 @@ + @@ -28,6 +29,7 @@ + diff --git a/src/apps/Elsa.ServerAndStudio.Web/Program.cs b/src/apps/Elsa.ServerAndStudio.Web/Program.cs index 01b787cad..2987cf105 100644 --- a/src/apps/Elsa.ServerAndStudio.Web/Program.cs +++ b/src/apps/Elsa.ServerAndStudio.Web/Program.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using WebhooksCore.Options; using Elsa.Connections.Middleware; +using Elsa.IO.Http.Features; using Proto.Persistence.Sqlite; const bool useMassTransit = true; @@ -140,6 +141,8 @@ services .UseEmail(email => email.ConfigureOptions = options => configuration.GetSection("Smtp").Bind(options)) .UseWebhooks(webhooks => webhooks.ConfigureSinks = options => builder.Configuration.GetSection("Webhooks:Sinks").Bind(options)) .UseWorkflowsApi() + .UseCompression() + .Use() .AddActivitiesFrom() .AddWorkflowsFrom(); diff --git a/src/modules/Elsa.Common/Features/CompressionFeature.cs b/src/modules/Elsa.Common/Features/StringCompressionFeature.cs similarity index 87% rename from src/modules/Elsa.Common/Features/CompressionFeature.cs rename to src/modules/Elsa.Common/Features/StringCompressionFeature.cs index f6b5955c1..5b2c2b0c6 100644 --- a/src/modules/Elsa.Common/Features/CompressionFeature.cs +++ b/src/modules/Elsa.Common/Features/StringCompressionFeature.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Elsa.Common.Features; [UsedImplicitly] -public class CompressionFeature(IModule module) : FeatureBase(module) +public class StringCompressionFeature(IModule module) : FeatureBase(module) { public override void Apply() { diff --git a/src/modules/Elsa.Http/Extensions/HeadersExtensions.cs b/src/modules/Elsa.Http/Extensions/HeadersExtensions.cs index 5c144c002..10f0b53c7 100644 --- a/src/modules/Elsa.Http/Extensions/HeadersExtensions.cs +++ b/src/modules/Elsa.Http/Extensions/HeadersExtensions.cs @@ -1,4 +1,6 @@ // ReSharper disable once CheckNamespace +using Elsa.Http; + namespace Elsa.Extensions; /// diff --git a/src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs b/src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs new file mode 100644 index 000000000..c7eee4d13 --- /dev/null +++ b/src/modules/Elsa.IO.Compression/Activities/CreateZipArchive.cs @@ -0,0 +1,140 @@ +using System.IO.Compression; +using System.Text.Json.Serialization; +using Elsa.Extensions; +using Elsa.IO.Contracts; +using Elsa.IO.Extensions; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; +using Microsoft.Extensions.Logging; + +namespace Elsa.IO.Compression.Activities; + +/// +/// Creates a ZIP archive from a collection of entries. +/// +[Activity("Elsa", "Compression", "Creates a ZIP archive from a collection of entries.", DisplayName = "Create Zip Archive")] +public class CreateZipArchive : CodeActivity +{ + private const string DefaultArchiveName = "archive.zip"; + private const string ZipExtension = ".zip"; + private const string DefaultEntryNameFormat = "entry_{0}"; + + /// + [JsonConstructor] + public CreateZipArchive(string? source = null, int? line = null) : base(source, line) + { + } + + /// + /// The entries to include in the ZIP archive. Can be byte[], Stream, file path, file URL, base64 string, ZipEntry objects, or arrays of these types. + /// + [Input( + Description = "The entries to include in the ZIP archive. Can be byte[], Stream, file path, file URL, base64 string, ZipEntry objects, or arrays of these types", + UIHint = InputUIHints.MultiLine + )] + public Input Entries { get; set; } = null!; + + /// + /// The compression level for the Zip Entries. Default is Optimal + /// + [Input( + Description = "The compression level for the Zip Entries. Default is Optimal", + UIHint = InputUIHints.DropDown + )] + public Input CompressionLevel { get; set; } = new(System.IO.Compression.CompressionLevel.Optimal); + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var entriesInput = Entries.Get(context); + var resolver = context.GetRequiredService(); + var logger = context.GetRequiredService>(); + + var entries = ParseEntries(entriesInput); + + var zipStream = await CreateZipStreamFromEntries(entries, resolver, context, logger); + + Result.Set(context, zipStream); + } + + private static IEnumerable ParseEntries(object? entriesInput) + { + return entriesInput switch + { + null => [], + IEnumerable enumerable => enumerable, + Array array => array.Cast(), + _ => [entriesInput] + }; + } + + private async Task CreateZipStreamFromEntries( + IEnumerable entries, + IContentResolver resolver, + ActivityExecutionContext context, + ILogger logger) + { + var zipStream = new MemoryStream(); + + try + { + using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true); + var entryIndex = 0; + + var compressionLevel = CompressionLevel.Get(context); + foreach (var entryContent in entries) + { + try + { + await ProcessZipEntry(entryContent, zipArchive, resolver, context, entryIndex, compressionLevel); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to add entry {EntryIndex} to ZIP archive. Reason: {ExceptionMessage}", + entryIndex, ex.Message); + } + entryIndex++; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create ZIP archive"); + await zipStream.DisposeAsync(); + throw; + } + + // Reset stream position for reading + zipStream.Position = 0; + return zipStream; + } + + /// + /// Processes a single zip entry and adds it to the archive. + /// + private static async Task ProcessZipEntry( + object entryContent, + ZipArchive zipArchive, + IContentResolver resolver, + ActivityExecutionContext context, + int entryIndex, + CompressionLevel compressionLevel) + { + var binaryContent = await resolver.ResolveAsync(entryContent, context.CancellationToken); + + var entryName = binaryContent.Name?.GetNameAndExtension() + ?? string.Format(DefaultEntryNameFormat, entryIndex + 1); + + var archiveEntry = zipArchive.CreateEntry(entryName, compressionLevel); + + await using var entryStream = archiveEntry.Open(); + await binaryContent.Stream.CopyToAsync(entryStream, context.CancellationToken); + await entryStream.FlushAsync(context.CancellationToken); + + if (entryContent is not Stream) + { + await binaryContent.Stream.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO.Compression/Common/Constants.cs b/src/modules/Elsa.IO.Compression/Common/Constants.cs new file mode 100644 index 000000000..f9e7a627e --- /dev/null +++ b/src/modules/Elsa.IO.Compression/Common/Constants.cs @@ -0,0 +1,6 @@ +namespace Elsa.IO.Compression.Common; + +public static class Constants +{ + public const float ZipEntryStrategyPriority = 0.5f; +} \ No newline at end of file diff --git a/src/modules/Elsa.IO.Compression/Elsa.IO.Compression.csproj b/src/modules/Elsa.IO.Compression/Elsa.IO.Compression.csproj new file mode 100644 index 000000000..f1b1d9145 --- /dev/null +++ b/src/modules/Elsa.IO.Compression/Elsa.IO.Compression.csproj @@ -0,0 +1,23 @@ + + + + + Provides compression and archiving activities for Elsa Workflows. + + elsa module compression zip archive workflows + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/Elsa.IO.Compression/Extensions/ModuleExtensions.cs b/src/modules/Elsa.IO.Compression/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..e471e4c49 --- /dev/null +++ b/src/modules/Elsa.IO.Compression/Extensions/ModuleExtensions.cs @@ -0,0 +1,20 @@ +using Elsa.IO.Compression.Features; +using Elsa.Features.Services; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Provides extensions to install the feature. +/// +public static class ModuleExtensions +{ + /// + /// Install the feature. + /// + public static IModule UseCompression(this IModule module, Action? configure = default) + { + module.Configure(configure); + return module; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO.Compression/Features/CompressionFeature.cs b/src/modules/Elsa.IO.Compression/Features/CompressionFeature.cs new file mode 100644 index 000000000..04bfb6ce4 --- /dev/null +++ b/src/modules/Elsa.IO.Compression/Features/CompressionFeature.cs @@ -0,0 +1,33 @@ +using Elsa.Extensions; +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.IO.Compression.Models; +using Elsa.IO.Compression.Services.Strategies; +using Elsa.IO.Features; +using Elsa.IO.Services.Strategies; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.IO.Compression.Features; + +/// +/// Configures compression activities and services. +/// +[UsedImplicitly] +[DependsOn(typeof(IOFeature))] +public class CompressionFeature(IModule module) : FeatureBase(module) +{ + /// + public override void Configure() + { + Module.AddActivitiesFrom(); + Module.AddVariableTypeAndAlias("ZipEntry", "Compression"); + } + + /// + public override void Apply() + { + Services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO.Compression/FodyWeavers.xml b/src/modules/Elsa.IO.Compression/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.IO.Compression/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.IO.Compression/Models/ZipEntry.cs b/src/modules/Elsa.IO.Compression/Models/ZipEntry.cs new file mode 100644 index 000000000..03986e6ce --- /dev/null +++ b/src/modules/Elsa.IO.Compression/Models/ZipEntry.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace Elsa.IO.Compression.Models; + +/// +/// Represents a zip entry with content and metadata. +/// +/// The content of the zip entry. Can be byte[], Stream, file path, file URL, or base64 string. +/// The name of the entry in the zip archive. +[UsedImplicitly] +public record ZipEntry(object Content, string? EntryName = null); \ No newline at end of file diff --git a/src/modules/Elsa.IO.Compression/Services/Strategies/ZipEntryContentStrategy.cs b/src/modules/Elsa.IO.Compression/Services/Strategies/ZipEntryContentStrategy.cs new file mode 100644 index 000000000..14757faa1 --- /dev/null +++ b/src/modules/Elsa.IO.Compression/Services/Strategies/ZipEntryContentStrategy.cs @@ -0,0 +1,44 @@ +using Elsa.IO.Compression.Common; +using Elsa.IO.Compression.Models; +using Elsa.IO.Contracts; +using Elsa.IO.Extensions; +using Elsa.IO.Models; +using Elsa.IO.Services.Strategies; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.IO.Compression.Services.Strategies; + +/// +/// Strategy for resolving ZipEntry content with proper entry names. +/// +public class ZipEntryContentStrategy(IServiceProvider serviceProvider) : IContentResolverStrategy +{ + /// + public float Priority => Constants.ZipEntryStrategyPriority; + + /// + public bool CanResolve(object content) => content is ZipEntry; + + /// + public async Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var zipEntry = (ZipEntry)content; + + var resolver = serviceProvider.GetRequiredService(); + + var innerContent = await resolver.ResolveAsync(zipEntry.Content, cancellationToken); + + if (string.IsNullOrEmpty(zipEntry.EntryName)) + { + return innerContent; + } + + var innerContentName = innerContent.Name?.GetNameAndExtension(); + var innerContentExtension = Path.GetExtension(innerContentName); + innerContent.Name = !string.IsNullOrWhiteSpace(innerContentExtension) + ? zipEntry.EntryName + innerContentExtension + : innerContent.Name; + + return innerContent; + } +} diff --git a/src/modules/Elsa.IO.Http/Common/Constants.cs b/src/modules/Elsa.IO.Http/Common/Constants.cs new file mode 100644 index 000000000..c785d5ee7 --- /dev/null +++ b/src/modules/Elsa.IO.Http/Common/Constants.cs @@ -0,0 +1,14 @@ +namespace Elsa.IO.Http.Common; + +public static class Constants +{ + /// + /// The name of the HTTP client used for IO operations. + /// + public const string IOFeatureHttpClient = "IOFeatureHttpClient"; + + public static class StrategyPriorities + { + public const float Uri = 2.5f; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj b/src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj new file mode 100644 index 000000000..eca9c5e79 --- /dev/null +++ b/src/modules/Elsa.IO.Http/Elsa.IO.Http.csproj @@ -0,0 +1,21 @@ + + + + + Provides http capabilities to IO modules in Elsa Workflows. + + elsa module io http + + + + + + + + + + + + + + diff --git a/src/modules/Elsa.IO.Http/Features/IOHttpFeature.cs b/src/modules/Elsa.IO.Http/Features/IOHttpFeature.cs new file mode 100644 index 000000000..90916619f --- /dev/null +++ b/src/modules/Elsa.IO.Http/Features/IOHttpFeature.cs @@ -0,0 +1,29 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.IO.Compression.Features; +using Elsa.IO.Features; +using Elsa.IO.Http.Common; +using Elsa.IO.Http.Services.Strategies; +using Elsa.IO.Services.Strategies; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.IO.Http.Features; + +/// +/// Configures HTTP-based IO services. +/// +[UsedImplicitly] +[DependsOn(typeof(IOFeature))] +[DependencyOf(typeof(CompressionFeature))] +public class IOHttpFeature(IModule module) : FeatureBase(module) +{ + /// + public override void Apply() + { + Services.AddHttpClient(Constants.IOFeatureHttpClient); + + Services.AddScoped(); + } +} diff --git a/src/modules/Elsa.IO.Http/FodyWeavers.xml b/src/modules/Elsa.IO.Http/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.IO.Http/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.IO.Http/Services/Strategies/UrlContentStrategy.cs b/src/modules/Elsa.IO.Http/Services/Strategies/UrlContentStrategy.cs new file mode 100644 index 000000000..5aece7553 --- /dev/null +++ b/src/modules/Elsa.IO.Http/Services/Strategies/UrlContentStrategy.cs @@ -0,0 +1,78 @@ +using Elsa.Extensions; +using Elsa.IO.Extensions; +using Elsa.IO.Http.Common; +using Elsa.IO.Models; +using Elsa.IO.Services.Strategies; +using Microsoft.Extensions.Logging; + +namespace Elsa.IO.Http.Services.Strategies; + +/// +/// Strategy for handling URL content by downloading from HTTP/HTTPS URLs. +/// +public class UrlContentStrategy(ILogger logger, IHttpClientFactory httpClientFactory) : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.Uri; + + /// + public bool CanResolve(object content) => content is string str && (str.StartsWith("http://") || str.StartsWith("https://")); + + /// + public async Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var url = (string)content; + + try + { + var httpClient = httpClientFactory.CreateClient(); + var response = await httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var filename = ExtractFilenameFromResponse(response, url); + var contentType = response.Content.Headers.ContentType?.MediaType; + + return new BinaryContent + { + Name = filename.GetNameAndExtension(contentType.GetExtensionFromContentType()), + Stream = stream + }; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to download file from URL: {Url}", url); + throw; + } + } + + /// + /// Extracts a filename from the HTTP response, either from Content-Disposition header or URL. + /// + private string ExtractFilenameFromResponse(HttpResponseMessage response, string url) + { + var filename = response.GetFilename(); + if (!string.IsNullOrWhiteSpace(filename)) + { + return filename; + } + + try + { + var uri = new Uri(url); + var path = uri.AbsolutePath; + filename = Path.GetFileName(path); + + if (!string.IsNullOrEmpty(filename) && Path.HasExtension(filename)) + { + return filename; + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to extract filename from URL: {Url}", url); + } + + return "download"; + } +} diff --git a/src/modules/Elsa.IO/Common/Constants.cs b/src/modules/Elsa.IO/Common/Constants.cs new file mode 100644 index 000000000..6444fc89c --- /dev/null +++ b/src/modules/Elsa.IO/Common/Constants.cs @@ -0,0 +1,38 @@ +namespace Elsa.IO.Common; + +/// +/// IO module constants. +/// +public static class Constants +{ + /// + /// Priorities for content resolver strategies. + /// + public static class StrategyPriorities + { + /// + /// Stream content priority. + /// + public const float Stream = 0.0f; + + /// + /// Byte array content priority. + /// + public const float ByteArray = 1.0f; + + /// + /// Base64 content priority. + /// + public const float Base64 = 2.0f; + + /// + /// File path content priority. + /// + public const float FilePath = 3.0f; + + /// + /// Text content priority. + /// + public const float Text = 100.0f; + } +} diff --git a/src/modules/Elsa.IO/Contracts/IContentResolver.cs b/src/modules/Elsa.IO/Contracts/IContentResolver.cs new file mode 100644 index 000000000..1ae60ca14 --- /dev/null +++ b/src/modules/Elsa.IO/Contracts/IContentResolver.cs @@ -0,0 +1,17 @@ +namespace Elsa.IO.Contracts; + +using Elsa.IO.Models; + +/// +/// Provides methods to resolve various content types to BinaryContent. +/// +public interface IContentResolver +{ + /// + /// Resolves arbitrary content to a BinaryContent object that includes the content stream and metadata. + /// + /// The content to resolve. Can be byte[], Stream, file path, file URL, base64 string, or plain text. + /// A cancellation token. + /// A BinaryContent object containing the content stream and associated metadata. + Task ResolveAsync(object content, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Elsa.IO.csproj b/src/modules/Elsa.IO/Elsa.IO.csproj new file mode 100644 index 000000000..f7dc3b034 --- /dev/null +++ b/src/modules/Elsa.IO/Elsa.IO.csproj @@ -0,0 +1,21 @@ + + + + + Provides IO services for resolving various content types to streams. + + elsa module io content streams + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/Elsa.IO/Extensions/ContentTypeExtensions.cs b/src/modules/Elsa.IO/Extensions/ContentTypeExtensions.cs new file mode 100644 index 000000000..0bb796073 --- /dev/null +++ b/src/modules/Elsa.IO/Extensions/ContentTypeExtensions.cs @@ -0,0 +1,110 @@ +namespace Elsa.IO.Extensions; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +public static class ContentTypeExtensions +{ + private static readonly Dictionary MimeMapping = new(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary> ContentTypeToExtensionsMap = new(StringComparer.OrdinalIgnoreCase); + + static ContentTypeExtensions() + { + // Define all mappings in a single place + AddMapping(".txt", "text/plain"); + AddMapping(".html", "text/html"); + AddMapping(".htm", "text/html"); + AddMapping(".css", "text/css"); + AddMapping(".js", "application/javascript"); + AddMapping(".json", "application/json"); + AddMapping(".xml", "application/xml"); + AddMapping(".jpg", "image/jpeg"); + AddMapping(".jpeg", "image/jpeg"); + AddMapping(".png", "image/png"); + AddMapping(".gif", "image/gif"); + AddMapping(".svg", "image/svg+xml"); + AddMapping(".pdf", "application/pdf"); + AddMapping(".doc", "application/msword"); + AddMapping(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + AddMapping(".xls", "application/vnd.ms-excel"); + AddMapping(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + AddMapping(".ppt", "application/vnd.ms-powerpoint"); + AddMapping(".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); + AddMapping(".zip", "application/zip"); + AddMapping(".csv", "text/csv"); + } + private static void AddMapping(string extension, string contentType) + { + // Map extension to content type + MimeMapping[extension] = contentType; + // Map content type to extension(s) + if (!ContentTypeToExtensionsMap.TryGetValue(contentType, out var extensions)) + { + extensions = new(StringComparer.OrdinalIgnoreCase); + ContentTypeToExtensionsMap[contentType] = extensions; + } + + extensions.Add(extension); + } + public static string GetExtensionFromContentType(this string? contentType) + { + if (string.IsNullOrEmpty(contentType)) + return ".bin"; + + if (contentType.EndsWith("/pdf") || contentType == "application/pdf") + return ".pdf"; + + if (ContentTypeToExtensionsMap.TryGetValue(contentType, out var extensions) && extensions.Any()) + { + // Return the first extension for this content type + return extensions.First(); + } + + return DetermineExtensionFromMimeType(contentType); + } + public static string GetContentTypeFromExtension(this string filePath) + { + var extension = filePath.GetFileExtension(); + + return MimeMapping.GetValueOrDefault(extension, "application/octet-stream"); + } + + public static string GetNameAndExtension(this string fileName, string? extension = ".bin") + { + var currentExtension = fileName.GetFileExtension(); + if (!string.IsNullOrWhiteSpace(currentExtension)) + { + return fileName; + } + + return fileName + extension; + } + + public static string GetFileExtension(this string filePath) + { + return Path.GetExtension(filePath).ToLowerInvariant(); + } + + private static string DetermineExtensionFromMimeType(string mimeType) + { + if (mimeType.Contains("/pdf")) + return ".pdf"; + if (mimeType.Contains("image/")) + return ".img"; + if (mimeType.Contains("text/")) + return ".txt"; + if (mimeType.Contains("audio/")) + return ".audio"; + if (mimeType.Contains("video/")) + return ".video"; + + if (mimeType.StartsWith("file/") || mimeType.StartsWith("@file/")) + { + var extension = mimeType[(mimeType.IndexOf('/') + 1)..]; + if (!string.IsNullOrWhiteSpace(extension)) + return "." + extension; + } + + return ".bin"; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Extensions/FilePathExtensions.cs b/src/modules/Elsa.IO/Extensions/FilePathExtensions.cs new file mode 100644 index 000000000..cb8456f57 --- /dev/null +++ b/src/modules/Elsa.IO/Extensions/FilePathExtensions.cs @@ -0,0 +1,18 @@ +namespace Elsa.IO.Extensions; + +public static class FilePathExtensions +{ + public static string CleanFilePath(this string filePath) + { + // Clean up the path - trim quotes and whitespace that might come from copy-paste + filePath = filePath.Trim().Trim('"', '\''); + + // Replace backslashes with forward slashes on Unix/Mac systems + if (Path.DirectorySeparatorChar == '/') + { + filePath = filePath.Replace('\\', '/'); + } + + return filePath; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Extensions/ModuleExtensions.cs b/src/modules/Elsa.IO/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..dcd9b4bb0 --- /dev/null +++ b/src/modules/Elsa.IO/Extensions/ModuleExtensions.cs @@ -0,0 +1,19 @@ +using Elsa.Extensions; +using Elsa.Features.Services; +using Elsa.IO.Features; + +namespace Elsa.IO.Extensions; + +/// +/// Provides extension methods for configuring IO services. +/// +public static class ModuleExtensions +{ + /// + /// Installs the IO module. + /// + public static IModule UseIO(this IModule module, Action? configure = null) + { + return module.Use(configure); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Features/IOFeature.cs b/src/modules/Elsa.IO/Features/IOFeature.cs new file mode 100644 index 000000000..52cb72312 --- /dev/null +++ b/src/modules/Elsa.IO/Features/IOFeature.cs @@ -0,0 +1,26 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.IO.Contracts; +using Elsa.IO.Services; +using Elsa.IO.Services.Strategies; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.IO.Features; + +/// +/// A feature that installs IO services for resolving various content types to streams. +/// +public class IOFeature(IModule module) : FeatureBase(module) +{ + /// + public override void Apply() + { + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); + + Services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/FodyWeavers.xml b/src/modules/Elsa.IO/FodyWeavers.xml new file mode 100644 index 000000000..06ee72161 --- /dev/null +++ b/src/modules/Elsa.IO/FodyWeavers.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/Elsa.IO/Models/BinaryContent.cs b/src/modules/Elsa.IO/Models/BinaryContent.cs new file mode 100644 index 000000000..ba5c7d2b1 --- /dev/null +++ b/src/modules/Elsa.IO/Models/BinaryContent.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.IO; +using Elsa.IO.Extensions; + +namespace Elsa.IO.Models; + +/// +/// Represents normalized binary content with metadata. +/// +public class BinaryContent +{ + /// + /// Gets or sets the name of the content. + /// + public string? Name { get; set; } + + /// + /// Gets the content type (MIME type) based on file extension. + /// + public string? ContentType => !string.IsNullOrWhiteSpace(Name) + ? Path.GetExtension(Name).GetContentTypeFromExtension() + : null; + + /// + /// Gets or sets optional metadata headers. + /// + public IDictionary Headers { get; set; } = new Dictionary(); + + /// + /// Gets or sets the content stream. + /// + public Stream Stream { get; init; } = null!; +} diff --git a/src/modules/Elsa.IO/Services/ContentResolver.cs b/src/modules/Elsa.IO/Services/ContentResolver.cs new file mode 100644 index 000000000..607bce68b --- /dev/null +++ b/src/modules/Elsa.IO/Services/ContentResolver.cs @@ -0,0 +1,34 @@ +using Elsa.IO.Contracts; +using Elsa.IO.Models; +using Elsa.IO.Services.Strategies; + +namespace Elsa.IO.Services; + +/// +/// Resolves various content types to BinaryContent using a strategy pattern. +/// +public class ContentResolver : IContentResolver +{ + private readonly IEnumerable _strategies; + + /// + /// Initializes a new instance of the class. + /// + public ContentResolver(IEnumerable strategies) + { + _strategies = strategies.OrderBy(s => s.Priority).ToList(); + } + + /// + public async Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var strategy = _strategies.FirstOrDefault(s => s.CanResolve(content)); + + if (strategy == null) + { + throw new ArgumentException($"Unsupported content type: {content.GetType().Name}"); + } + + return await strategy.ResolveAsync(content, cancellationToken); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Services/Strategies/Base64ContentStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/Base64ContentStrategy.cs new file mode 100644 index 000000000..a1d621c9b --- /dev/null +++ b/src/modules/Elsa.IO/Services/Strategies/Base64ContentStrategy.cs @@ -0,0 +1,67 @@ +using Elsa.IO.Common; +using Elsa.IO.Extensions; +using Elsa.IO.Models; + +namespace Elsa.IO.Services.Strategies; + +/// +/// Strategy for handling base64 encoded content. +/// +public class Base64ContentStrategy : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.Base64; + + /// + public bool CanResolve(object content) + { + return content is string str && IsBase64String(str); + } + + /// + public Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var str = content.ToString()!; + var extension = ".bin"; + string? name = null; + + if (IsUriDataBase64String(str)) + { + var dataUrlParts = str.Split(';'); + if (dataUrlParts.Length > 0 && dataUrlParts[0].StartsWith("data:")) + { + var contentType = dataUrlParts[0][5..]; + extension = contentType.GetExtensionFromContentType(); + + name = "data" + extension; + } + + str = str[(str.IndexOf("base64,", StringComparison.Ordinal) + 7)..]; + } + + var base64Bytes = Convert.FromBase64String(str); + var stream = new MemoryStream(base64Bytes); + + return Task.FromResult(new BinaryContent + { + Name = name?.GetNameAndExtension(extension) ?? "data.bin", + Stream = stream, + }); + } + + private static bool IsBase64String(string base64) + { + if (IsUriDataBase64String(base64)) + { + return true; + } + + var buffer = new Span(new byte[base64.Length]); + return Convert.TryFromBase64String(base64, buffer , out _); + } + + private static bool IsUriDataBase64String(string base64) + { + return base64.StartsWith("data:") && base64.Contains("base64"); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Services/Strategies/ByteArrayContentStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/ByteArrayContentStrategy.cs new file mode 100644 index 000000000..71b5f1658 --- /dev/null +++ b/src/modules/Elsa.IO/Services/Strategies/ByteArrayContentStrategy.cs @@ -0,0 +1,31 @@ +using Elsa.IO.Common; +using Elsa.IO.Models; + +namespace Elsa.IO.Services.Strategies; + +/// +/// Strategy for handling byte array content. +/// +public class ByteArrayContentStrategy : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.ByteArray; + + /// + public bool CanResolve(object content) => content is byte[]; + + /// + public Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var bytes = (byte[])content; + var stream = new MemoryStream(bytes); + + var result = new BinaryContent + { + Stream = stream, + Name = "data.bin", + }; + + return Task.FromResult(result); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Services/Strategies/FilePathContentStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/FilePathContentStrategy.cs new file mode 100644 index 000000000..5f974cccf --- /dev/null +++ b/src/modules/Elsa.IO/Services/Strategies/FilePathContentStrategy.cs @@ -0,0 +1,89 @@ +using Elsa.IO.Common; +using Elsa.IO.Extensions; +using Elsa.IO.Models; + +namespace Elsa.IO.Services.Strategies; + +/// +/// Strategy for handling file path content by reading from the filesystem. +/// +public class FilePathContentStrategy : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.FilePath; + + /// + public bool CanResolve(object content) + { + if (content is not string filePath) + { + return false; + } + + filePath = filePath.CleanFilePath(); + + try + { + if (Path.IsPathRooted(filePath) && File.Exists(filePath)) + { + return true; + } + + var normalized = Path.GetFullPath(filePath); + if (File.Exists(normalized)) + { + return true; + } + + var combined = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), filePath)); + return File.Exists(combined); + } + catch (Exception) + { + return false; + } + } + + /// + public Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + try + { + var filePath = (string)content; + filePath = ResolveActualPath(filePath); + + var fileName = Path.GetFileName(filePath); + var fileStream = File.OpenRead(filePath); + + var result = new BinaryContent + { + Name = fileName.GetNameAndExtension(), + Stream = fileStream + }; + + return Task.FromResult(result); + } + catch (Exception ex) when (ex is not FileNotFoundException) + { + throw new FileNotFoundException($"Error opening file: {content}", content.ToString(), ex); + } + } + + private string ResolveActualPath(string filePath) + { + filePath = filePath.CleanFilePath(); + + if (Path.IsPathRooted(filePath) && File.Exists(filePath)) + return filePath; + + var normalized = Path.GetFullPath(filePath); + if (File.Exists(normalized)) + return normalized; + + var combined = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), filePath)); + if (File.Exists(combined)) + return combined; + + throw new FileNotFoundException($"Could not find file at path: {filePath}", filePath); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Services/Strategies/IContentResolverStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/IContentResolverStrategy.cs new file mode 100644 index 000000000..680ade5fb --- /dev/null +++ b/src/modules/Elsa.IO/Services/Strategies/IContentResolverStrategy.cs @@ -0,0 +1,29 @@ +namespace Elsa.IO.Services.Strategies; + +using Elsa.IO.Models; + +/// +/// Defines a strategy for resolving specific content types to BinaryContent. +/// +public interface IContentResolverStrategy +{ + /// + /// The priority of the strategy. + /// + float Priority { get; } + + /// + /// Determines if this strategy can handle the specified content. + /// + /// The content to check. + /// True if this strategy can handle the content, false otherwise. + bool CanResolve(object content); + + /// + /// Resolves the content to a BinaryContent object that includes the content stream and metadata. + /// + /// The content to resolve. + /// A cancellation token. + /// A BinaryContent object containing the content stream and associated metadata. + Task ResolveAsync(object content, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Services/Strategies/StreamContentStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/StreamContentStrategy.cs new file mode 100644 index 000000000..538f7bcd7 --- /dev/null +++ b/src/modules/Elsa.IO/Services/Strategies/StreamContentStrategy.cs @@ -0,0 +1,37 @@ +using Elsa.IO.Common; +using Elsa.IO.Extensions; +using Elsa.IO.Models; + +namespace Elsa.IO.Services.Strategies; + +/// +/// Strategy for handling Stream content. +/// +public class StreamContentStrategy : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.Stream; + + /// + public bool CanResolve(object content) => content is Stream; + + /// + public Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var stream = (Stream)content; + + string? name = null; + if (stream is FileStream fileStream) + { + name = Path.GetFileName(fileStream.Name); + } + + var result = new BinaryContent + { + Stream = stream, + Name = name?.GetNameAndExtension() ?? "data.bin", + }; + + return Task.FromResult(result); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.IO/Services/Strategies/TextContentStrategy.cs b/src/modules/Elsa.IO/Services/Strategies/TextContentStrategy.cs new file mode 100644 index 000000000..23e5992b8 --- /dev/null +++ b/src/modules/Elsa.IO/Services/Strategies/TextContentStrategy.cs @@ -0,0 +1,31 @@ +using System.Text; +using Elsa.IO.Common; +using Elsa.IO.Models; + +namespace Elsa.IO.Services.Strategies; + +/// +/// Strategy for handling plain text content by encoding as UTF-8. +/// +public class TextContentStrategy : IContentResolverStrategy +{ + /// + public float Priority => Constants.StrategyPriorities.Text; + + /// + public bool CanResolve(object content) => content is string; + + /// + public Task ResolveAsync(object content, CancellationToken cancellationToken = default) + { + var textContent = (string)content; + var textBytes = Encoding.UTF8.GetBytes(textContent); + var stream = new MemoryStream(textBytes); + + return Task.FromResult(new BinaryContent + { + Name = "text.txt", + Stream = stream + }); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariableTypes.cs b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariableTypes.cs new file mode 100644 index 000000000..146bdb735 --- /dev/null +++ b/src/modules/Elsa.JavaScript/Handlers/ConfigureEngineWithVariableTypes.cs @@ -0,0 +1,24 @@ +using Elsa.Extensions; +using Elsa.JavaScript.Notifications; +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Management.Options; +using JetBrains.Annotations; +using Microsoft.Extensions.Options; + +namespace Elsa.JavaScript.Handlers; + +[UsedImplicitly] +public class ConfigureEngineWithWorkflowVariableTypes(IOptions options) + : INotificationHandler +{ + /// + public Task HandleAsync(EvaluatingJavaScript notification, CancellationToken cancellationToken) + { + var engine = notification.Engine; + foreach (var variableDescriptor in + options.Value.VariableDescriptors.Where(x => x.Type is { ContainsGenericParameters: false })) + engine.RegisterType(variableDescriptor.Type); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 76e53ea19..ff369a7c4 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -34,7 +34,7 @@ namespace Elsa.Workflows.Management.Features; /// /// Installs and configures the workflow management feature. /// -[DependsOn(typeof(CompressionFeature))] +[DependsOn(typeof(StringCompressionFeature))] [DependsOn(typeof(MediatorFeature))] [DependsOn(typeof(MemoryCacheFeature))] [DependsOn(typeof(SystemClockFeature))] From b8a04b11d12628d9de94abd6bb0c99a5fb877cea Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 3 Jul 2025 22:21:40 +0200 Subject: [PATCH 13/17] Register `FlowScope` class map in MongoDB feature to handle serialization and ignore extra elements. (#6766) --- src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs b/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs index 09c7afba3..4fa93b3fd 100644 --- a/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs +++ b/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs @@ -78,6 +78,12 @@ public class MongoDbFeature(IModule module) : FeatureBase(module) map.SetIgnoreExtraElements(true); // Needed for missing ID property map.MapProperty(x => x.Key); // Needed for non-setter property }); + + BsonClassMap.TryRegisterClassMap(map => + { + map.AutoMap(); + map.SetIgnoreExtraElements(true); + }); } private static void TryRegisterSerializerOrSkipWhenExist(Type type, IBsonSerializer serializer) From ea9c94de7e9d19333c2d2699f7e7428a57f22a04 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Thu, 3 Jul 2025 22:24:37 +0200 Subject: [PATCH 14/17] Add missing import for `Flowchart.Models` in `MongoDbFeature` --- src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs b/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs index 4fa93b3fd..a6b815b70 100644 --- a/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs +++ b/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs @@ -7,6 +7,7 @@ using Elsa.MongoDb.Contracts; using Elsa.MongoDb.NamingStrategies; using Elsa.MongoDb.Options; using Elsa.MongoDb.Serializers; +using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Memory; using Elsa.Workflows.Runtime.Entities; using Microsoft.Extensions.DependencyInjection; From 9a82b8b66b79ea35eec7c5c0ab9e0cfb43923e5f Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 4 Jul 2025 09:34:00 +0200 Subject: [PATCH 15/17] Add backward compatibility for `WorkflowInstance.Name` mapping in `WorkflowStateMapper` (#6767) * Add backward compatibility for `WorkflowInstance.Name` mapping in `WorkflowStateMapper` - Introduced constant `WorkflowInstanceNameKey` to handle legacy workflow instance name properties. - Updated `MapWorkflowStateToWorkflowInstance` method to set `Name` property for older instances. * Remove unused `Elsa.Workflows.Activities` import from `WorkflowStateMapper` * Update src/modules/Elsa.Workflows.Management/Mappers/WorkflowStateMapper.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Mappers/WorkflowStateMapper.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/modules/Elsa.Workflows.Management/Mappers/WorkflowStateMapper.cs b/src/modules/Elsa.Workflows.Management/Mappers/WorkflowStateMapper.cs index cea8e3a2a..deccb8170 100644 --- a/src/modules/Elsa.Workflows.Management/Mappers/WorkflowStateMapper.cs +++ b/src/modules/Elsa.Workflows.Management/Mappers/WorkflowStateMapper.cs @@ -1,3 +1,4 @@ +using Elsa.Extensions; using Elsa.Workflows.Management.Entities; using Elsa.Workflows.State; @@ -8,6 +9,12 @@ namespace Elsa.Workflows.Management.Mappers; /// public class WorkflowStateMapper { + /// + /// [Obsolete] The property key name used to store the workflow instance name. + /// + [Obsolete("This constant is obsolete and retained only for backward compatibility. Avoid using it in new code.")] + private const string WorkflowInstanceNameKey = "WorkflowInstanceName"; + /// /// Maps a workflow state to a workflow instance. /// @@ -43,6 +50,10 @@ public class WorkflowStateMapper target.UpdatedAt = source.UpdatedAt; target.FinishedAt = source.FinishedAt; target.WorkflowState = source; + + // Keep for backward compatibility with workflow instances created before the introduction of the Name property. + if (source.Properties.TryGetValue(WorkflowInstanceNameKey, out var name)) + target.Name = name; } /// From 1a554e7e067a0a1154eae3b880dd0b331e3ddf01 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 4 Jul 2025 09:46:20 +0200 Subject: [PATCH 16/17] Fix MongoDB serialization issues (#6762) * Ongoing MongoDB work * Refactor MongoDB serializer configuration and improve type handling - Added `BsonSerializerHelpers` for streamlined serializer registration with error handling. - Introduced `ConfigureMongoDbSerializers` hosted service to centralize serializer setup. - Enhanced `FlowScopeSerializer` to handle additional BSON types. - Cleaned up and reorganized MongoDB feature implementations, removing redundant code and improving consistency. * Remove `BsonSerializerHelpers` and update MongoDB serializer registration - Deleted `BsonSerializerHelpers` as it was redundant. - Updated `ConfigureMongoDbSerializers` to directly register serializers using `BsonSerializer`. - Simplified `FlowScopeSerializer` null handling for improved readability. * Switch persistence provider to Entity Framework Core in `Program.cs`. * Update src/modules/Elsa.MongoDb/Serializers/FlowScopeSerializer.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Configures MongoDB services and options. Configures MongoDB services, including client and database creation. Registers default naming strategy and collection naming strategy. Adds BsonClassMap configuration for KeyValuePair. * Simplify null check in `FlowScopeSerializer`. * Allow `FlowScopeSerializer` to handle nullable `FlowScope`. * Registers FlowScope BSON class map Registers the `FlowScope` class with BSON to enable proper serialization and deserialization of workflow scopes within MongoDB. This ensures that workflow scopes, which are used to manage variables within a workflow, are correctly stored and retrieved from the database. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Elsa.Features/Implementations/Module.cs | 12 +++-- src/common/Elsa.Features/Services/IModule.cs | 4 +- .../Elsa.MongoDb/Common/MongoDbStore.cs | 10 +++- .../Elsa.MongoDb/Features/MongoDbFeature.cs | 47 ++++++----------- .../Elsa.MongoDb/Helpers/ExpressionHelpers.cs | 25 ++++++--- .../ConfigureMongoDbSerializers.cs | 43 +++++++++++++++ .../Management/WorkflowInstanceStore.cs | 8 ++- .../Serializers/FlowScopeSerializer.cs | 52 +++++++++++++++++++ 8 files changed, 152 insertions(+), 49 deletions(-) create mode 100644 src/modules/Elsa.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs create mode 100644 src/modules/Elsa.MongoDb/Serializers/FlowScopeSerializer.cs diff --git a/src/common/Elsa.Features/Implementations/Module.cs b/src/common/Elsa.Features/Implementations/Module.cs index c64056133..4821e91d7 100644 --- a/src/common/Elsa.Features/Implementations/Module.cs +++ b/src/common/Elsa.Features/Implementations/Module.cs @@ -47,11 +47,13 @@ public class Module : IModule } /// - public T Configure(Action? configure = default) where T : class, IFeature - => Configure(module => (T)Activator.CreateInstance(typeof(T), module)!, configure); + public T Configure(Action? configure = null) where T : class, IFeature + { + return Configure(module => (T)Activator.CreateInstance(typeof(T), module)!, configure); + } /// - public T Configure(Func factory, Action? configure = default) where T : class, IFeature + public T Configure(Func factory, Action? configure = null) where T : class, IFeature { if (!_features.TryGetValue(typeof(T), out var feature)) { @@ -81,7 +83,7 @@ public class Module : IModule /// public IModule ConfigureHostedService(Type hostedServiceType, int priority = 0) { - _hostedServiceDescriptors.Add(new HostedServiceDescriptor(priority, hostedServiceType)); + _hostedServiceDescriptors.Add(new(priority, hostedServiceType)); return this; } @@ -121,7 +123,7 @@ public class Module : IModule var ns = "Elsa"; var displayName = type.GetCustomAttribute()?.DisplayName ?? name; var description = type.GetCustomAttribute()?.Description; - registry.Add(new FeatureDescriptor(name, ns, displayName, description)); + registry.Add(new(name, ns, displayName, description)); } Services.AddSingleton(registry); diff --git a/src/common/Elsa.Features/Services/IModule.cs b/src/common/Elsa.Features/Services/IModule.cs index 48d45795a..da070d3f6 100644 --- a/src/common/Elsa.Features/Services/IModule.cs +++ b/src/common/Elsa.Features/Services/IModule.cs @@ -31,12 +31,12 @@ public interface IModule /// /// Creates and configures a feature of the specified type. /// - T Configure(Action? configure = default) where T : class, IFeature; + T Configure(Action? configure = null) where T : class, IFeature; /// /// Creates and configures a feature of the specified type. /// - T Configure(Func factory, Action? configure = default) where T : class, IFeature; + T Configure(Func factory, Action? configure = null) where T : class, IFeature; /// /// Configures a using an optional priority to control in which order it will be registered with the service container. diff --git a/src/modules/Elsa.MongoDb/Common/MongoDbStore.cs b/src/modules/Elsa.MongoDb/Common/MongoDbStore.cs index e7135b2c9..a188ee69a 100644 --- a/src/modules/Elsa.MongoDb/Common/MongoDbStore.cs +++ b/src/modules/Elsa.MongoDb/Common/MongoDbStore.cs @@ -134,10 +134,11 @@ public class MongoDbStore(IMongoCollection collection, ITe await collection.BulkWriteAsync(writes, cancellationToken: cancellationToken); } - public async Task UpdatePartialAsync( + public async Task UpdatePartialAsync( string id, IDictionary updatedFields, string primaryKey = nameof(Entity.Id), + bool throwIfNotFound = true, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(id)) @@ -154,7 +155,14 @@ public class MongoDbStore(IMongoCollection collection, ITe var updateResult = await collection.UpdateOneAsync(filter, updateDefinition, cancellationToken: cancellationToken); if (updateResult.MatchedCount == 0) + { + if (!throwIfNotFound) + return false; + throw new InvalidOperationException($"No document found with ID '{id}'."); + } + + return updateResult.ModifiedCount > 0; } /// diff --git a/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs b/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs index 09c7afba3..0d9b3b057 100644 --- a/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs +++ b/src/modules/Elsa.MongoDb/Features/MongoDbFeature.cs @@ -1,20 +1,16 @@ -using System.Text.Json; -using System.Text.Json.Nodes; using Elsa.Features.Abstractions; using Elsa.Features.Services; using Elsa.KeyValues.Entities; using Elsa.MongoDb.Contracts; +using Elsa.MongoDb.HostedServices; using Elsa.MongoDb.NamingStrategies; using Elsa.MongoDb.Options; -using Elsa.MongoDb.Serializers; -using Elsa.Workflows.Memory; +using Elsa.Workflows.Activities.Flowchart.Models; using Elsa.Workflows.Runtime.Entities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; -using MongoDB.Bson; using MongoDB.Bson.Serialization; -using MongoDB.Bson.Serialization.Serializers; using MongoDB.Driver; namespace Elsa.MongoDb.Features; @@ -27,7 +23,7 @@ public class MongoDbFeature(IModule module) : FeatureBase(module) /// /// The MongoDB connection string. /// - public string ConnectionString { get; set; } = default!; + public string ConnectionString { get; set; } = null!; /// /// A delegate that configures MongoDb. @@ -39,6 +35,11 @@ public class MongoDbFeature(IModule module) : FeatureBase(module) /// public Func CollectionNamingStrategy { get; set; } = sp => sp.GetRequiredService(); + public override void ConfigureHostedServices() + { + Module.ConfigureHostedService(-10); + } + /// public override void Apply() { @@ -47,24 +48,13 @@ public class MongoDbFeature(IModule module) : FeatureBase(module) var mongoUrl = new MongoUrl(ConnectionString); Services.AddSingleton(sp => CreateMongoClient(sp, mongoUrl)); Services.AddScoped(sp => CreateDatabase(sp, mongoUrl)); - + Services.TryAddScoped(); Services.AddScoped(CollectionNamingStrategy); - RegisterSerializers(); RegisterClassMaps(); } - private static void RegisterSerializers() - { - TryRegisterSerializerOrSkipWhenExist(typeof(object), new PolymorphicSerializer()); - TryRegisterSerializerOrSkipWhenExist(typeof(Type), new TypeSerializer()); - TryRegisterSerializerOrSkipWhenExist(typeof(Variable), new VariableSerializer()); - TryRegisterSerializerOrSkipWhenExist(typeof(Version), new VersionSerializer()); - TryRegisterSerializerOrSkipWhenExist(typeof(JsonElement), new JsonElementSerializer()); - TryRegisterSerializerOrSkipWhenExist(typeof(JsonNode), new JsonNodeBsonConverter()); - } - private static void RegisterClassMaps() { BsonClassMap.TryRegisterClassMap(cm => @@ -78,19 +68,14 @@ public class MongoDbFeature(IModule module) : FeatureBase(module) map.SetIgnoreExtraElements(true); // Needed for missing ID property map.MapProperty(x => x.Key); // Needed for non-setter property }); + + BsonClassMap.TryRegisterClassMap(map => + { + map.AutoMap(); + map.SetIgnoreExtraElements(true); + }); } - private static void TryRegisterSerializerOrSkipWhenExist(Type type, IBsonSerializer serializer) - { - try - { - BsonSerializer.TryRegisterSerializer(type, serializer); - } - catch (BsonSerializationException ex) - { - } - } - private static IMongoClient CreateMongoClient(IServiceProvider sp, MongoUrl mongoUrl) { var options = sp.GetRequiredService>().Value; @@ -99,7 +84,7 @@ public class MongoDbFeature(IModule module) : FeatureBase(module) // TODO: Uncomment once https://github.com/jbogard/MongoDB.Driver.Core.Extensions.DiagnosticSources/pull/41 is merged and deployed. //settings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber()); - + settings.ApplicationName = GetApplicationName(settings); settings.WriteConcern = options.WriteConcern; settings.ReadConcern = options.ReadConcern; diff --git a/src/modules/Elsa.MongoDb/Helpers/ExpressionHelpers.cs b/src/modules/Elsa.MongoDb/Helpers/ExpressionHelpers.cs index e6514a25c..88db796fc 100644 --- a/src/modules/Elsa.MongoDb/Helpers/ExpressionHelpers.cs +++ b/src/modules/Elsa.MongoDb/Helpers/ExpressionHelpers.cs @@ -5,10 +5,10 @@ using Elsa.Workflows.Runtime.Entities; namespace Elsa.MongoDb.Helpers; -internal class ExpressionHelpers +internal static class ExpressionHelpers { public static readonly Expression> WorkflowDefinitionSummary = - workflowDefinition => new WorkflowDefinitionSummary + workflowDefinition => new() { Id = workflowDefinition.Id, DefinitionId = workflowDefinition.DefinitionId, @@ -18,11 +18,14 @@ internal class ExpressionHelpers IsLatest = workflowDefinition.IsLatest, IsPublished = workflowDefinition.IsPublished, MaterializerName = workflowDefinition.MaterializerName, - CreatedAt = workflowDefinition.CreatedAt + CreatedAt = workflowDefinition.CreatedAt, + IsReadonly = workflowDefinition.IsReadonly, + ProviderName = workflowDefinition.ProviderName, + ToolVersion = workflowDefinition.ToolVersion }; public static readonly Expression> WorkflowInstanceSummary = - workflowInstance => new WorkflowInstanceSummary + workflowInstance => new() { Id = workflowInstance.Id, DefinitionId = workflowInstance.DefinitionId, @@ -34,16 +37,17 @@ internal class ExpressionHelpers Name = workflowInstance.Name, CreatedAt = workflowInstance.CreatedAt, UpdatedAt = workflowInstance.UpdatedAt, - FinishedAt = workflowInstance.FinishedAt + FinishedAt = workflowInstance.FinishedAt, + IncidentCount = workflowInstance.IncidentCount, }; - public static readonly Expression> WorkflowInstanceId = workflowInstance => new WorkflowInstanceId + public static readonly Expression> WorkflowInstanceId = workflowInstance => new() { Id = workflowInstance.Id }; public static readonly Expression> ActivityExecutionRecordSummary = - workflowInstance => new ActivityExecutionRecordSummary + workflowInstance => new() { Id = workflowInstance.Id, Status = workflowInstance.Status, @@ -53,6 +57,11 @@ internal class ExpressionHelpers ActivityTypeVersion = workflowInstance.ActivityTypeVersion, ActivityName = workflowInstance.ActivityName, StartedAt = workflowInstance.StartedAt, - HasBookmarks = workflowInstance.HasBookmarks + HasBookmarks = workflowInstance.HasBookmarks, + CompletedAt = workflowInstance.CompletedAt, + AggregateFaultCount = workflowInstance.AggregateFaultCount, + Metadata = workflowInstance.Metadata, + WorkflowInstanceId = workflowInstance.WorkflowInstanceId, + TenantId = workflowInstance.TenantId, }; } \ No newline at end of file diff --git a/src/modules/Elsa.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs b/src/modules/Elsa.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs new file mode 100644 index 000000000..927bc81b0 --- /dev/null +++ b/src/modules/Elsa.MongoDb/HostedServices/ConfigureMongoDbSerializers.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Elsa.MongoDb.Serializers; +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Models; +using Elsa.Workflows.Memory; +using JetBrains.Annotations; +using Microsoft.Extensions.Hosting; +using MongoDB.Bson.Serialization.Serializers; +using static MongoDB.Bson.Serialization.BsonSerializer; + +namespace Elsa.MongoDb.HostedServices; + +/// +/// A hosted service that configures and registers custom MongoDB serializers for various types. +/// +/// +/// This class implements and is responsible for registering serializers to handle +/// specific types such as , , , , +/// , , and . +/// It uses helper methods to register these serializers during the application's startup process. +/// +[UsedImplicitly] +public class ConfigureMongoDbSerializers(IPayloadSerializer payloadSerializer) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + TryRegisterSerializer(typeof(object), new PolymorphicSerializer()); + TryRegisterSerializer(typeof(Type), new TypeSerializer()); + TryRegisterSerializer(typeof(Variable), new VariableSerializer()); + TryRegisterSerializer(typeof(Version), new VersionSerializer()); + TryRegisterSerializer(typeof(JsonElement), new JsonElementSerializer()); + TryRegisterSerializer(typeof(JsonNode), new JsonNodeBsonConverter()); + TryRegisterSerializer(typeof(FlowScope), new FlowScopeSerializer(payloadSerializer)); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.MongoDb/Modules/Management/WorkflowInstanceStore.cs b/src/modules/Elsa.MongoDb/Modules/Management/WorkflowInstanceStore.cs index 6913ff35f..03e95c3a5 100644 --- a/src/modules/Elsa.MongoDb/Modules/Management/WorkflowInstanceStore.cs +++ b/src/modules/Elsa.MongoDb/Modules/Management/WorkflowInstanceStore.cs @@ -8,6 +8,7 @@ using Elsa.Workflows.Management.Entities; using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Management.Models; using JetBrains.Annotations; +using Microsoft.Extensions.Logging; using MongoDB.Driver; using MongoDB.Driver.Linq; using Open.Linq.AsyncExtensions; @@ -18,7 +19,7 @@ namespace Elsa.MongoDb.Modules.Management; /// A MongoDb implementation of . /// [UsedImplicitly] -public class MongoWorkflowInstanceStore(MongoDbStore mongoDbStore) : IWorkflowInstanceStore +public class MongoWorkflowInstanceStore(MongoDbStore mongoDbStore, ILogger logger) : IWorkflowInstanceStore { /// public async ValueTask FindAsync(WorkflowInstanceFilter filter, CancellationToken cancellationToken = default) @@ -130,7 +131,10 @@ public class MongoWorkflowInstanceStore(MongoDbStore mongoDbSt [nameof(WorkflowInstance.UpdatedAt)] = value }; - await mongoDbStore.UpdatePartialAsync(workflowInstanceId, props, cancellationToken: cancellationToken); + var updated = await mongoDbStore.UpdatePartialAsync(workflowInstanceId, props, throwIfNotFound: false, cancellationToken: cancellationToken); + + if (!updated) + logger.LogDebug("Failed to update the 'UpdatedAt' timestamp for workflow instance with ID '{WorkflowInstanceId}'. This means this workflow does not yet exist in the DB.", workflowInstanceId); } /// diff --git a/src/modules/Elsa.MongoDb/Serializers/FlowScopeSerializer.cs b/src/modules/Elsa.MongoDb/Serializers/FlowScopeSerializer.cs new file mode 100644 index 000000000..f6d78e931 --- /dev/null +++ b/src/modules/Elsa.MongoDb/Serializers/FlowScopeSerializer.cs @@ -0,0 +1,52 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities.Flowchart.Models; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; + +namespace Elsa.MongoDb.Serializers; + +/// +/// Serializes a . +/// +public class FlowScopeSerializer(IPayloadSerializer payloadSerializer) : IBsonSerializer +{ + /// + public Type ValueType => typeof(FlowScope); + + void IBsonSerializer.Serialize(BsonSerializationContext context, BsonSerializationArgs args, object value) => Serialize(context, args, (FlowScope)value); + object IBsonSerializer.Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) => Deserialize(context, args); + + /// + public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, FlowScope? value) + { + if (value is null) + context.Writer.WriteNull(); + else + { + var json = payloadSerializer.Serialize(value); + context.Writer.WriteString(json); + } + } + + /// + public FlowScope Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var reader = context.Reader; + var bsonType = reader.GetCurrentBsonType(); + + if (bsonType == BsonType.Null) + { + reader.ReadNull(); + return new(); + } + + if(bsonType == BsonType.String) + { + var json = context.Reader.ReadString(); + + return string.IsNullOrEmpty(json) ? new() : payloadSerializer.Deserialize(json); + } + + return new(); + } +} \ No newline at end of file From 45bfcc9eeb76889b3a589445446a161f38d4705b Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Fri, 4 Jul 2025 09:58:09 +0200 Subject: [PATCH 17/17] Update release workflow for 3.4.2 patch tracking Adjusted branch filtering logic in `packages.yml` to track `3.4.2` patch release. --- .github/workflows/packages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 28c63d94d..fb98689ee 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -46,7 +46,7 @@ jobs: run: | if [[ "${{ github.ref }}" == refs/tags/* && "${{ github.event_name }}" == "release" && ("${{ github.event.action }}" == "published" || "${{ github.event.action }}" == "prereleased")]]; then git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* - git branch --remote --contains | grep origin/patch/3.4.1 + git branch --remote --contains | grep origin/patch/3.4.2 else git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* git branch --remote --contains | grep origin/${BRANCH_NAME}