diff --git a/src/designer/elsa-workflows-designer/src/components.d.ts b/src/designer/elsa-workflows-designer/src/components.d.ts index 9187c1ac1..304ab97f0 100644 --- a/src/designer/elsa-workflows-designer/src/components.d.ts +++ b/src/designer/elsa-workflows-designer/src/components.d.ts @@ -112,6 +112,7 @@ export namespace Components { "addActivity": (args: AddActivityArgs) => Promise; "autoLayout": (direction: LayoutDirection) => Promise; "export": () => Promise; + "getActivity": (id: string) => Promise; "getGraph": () => Promise; "interactiveMode": boolean; "newRoot": () => Promise; diff --git a/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variable-editor-dialog-content.tsx b/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variable-editor-dialog-content.tsx index 34e48271f..aabfbc143 100644 --- a/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variable-editor-dialog-content.tsx +++ b/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variable-editor-dialog-content.tsx @@ -1,7 +1,7 @@ import {Component, h, Prop, Event, EventEmitter, Method} from "@stencil/core"; import {groupBy} from 'lodash'; import {StorageDriverDescriptor, Variable} from "../../../models"; -import {FormEntry} from "../../shared/forms/form-entry"; +import {CheckboxFormEntry, FormEntry} from "../../shared/forms/form-entry"; import {isNullOrWhitespace} from "../../../utils"; import descriptorsStore from '../../../data/descriptors-store'; import {VariableDescriptor} from "../../../services/api-client/variable-descriptors-api"; @@ -22,7 +22,7 @@ export class VariableEditorDialogContent { } render() { - const variable: Variable = this.variable ?? {name: '', typeName: 'Object'}; + const variable: Variable = this.variable ?? {name: '', typeName: 'Object', isArray: false}; const variableTypeName = variable.typeName; const availableTypes: Array = descriptorsStore.variableDescriptors; const groupedVariableTypes = groupBy(availableTypes, x => x.category); @@ -50,6 +50,10 @@ export class VariableEditorDialogContent { + + + + @@ -84,12 +88,14 @@ export class VariableEditorDialogContent { const name = formData.get('variableName') as string; const value = formData.get('variableValue') as string; const type = formData.get('variableTypeName') as string; + const isArray = formData.get('variableIsArray') as string == 'true'; const driverTypeName = formData.get('variableStorageDriverTypeName') as string; const variable = this.variable; variable.name = name; variable.typeName = type; variable.value = value; + variable.isArray = isArray; variable.storageDriverTypeName = isNullOrWhitespace(driverTypeName) ? null : driverTypeName; return variable; diff --git a/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variables-editor.tsx b/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variables-editor.tsx index 90ff2666b..5f211ccfc 100644 --- a/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variables-editor.tsx +++ b/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variables-editor.tsx @@ -109,7 +109,7 @@ export class VariablesEditor { private onAddVariableClick = async () => { const newVariableName = this.generateNewVariableName(); - const variable = {name: newVariableName, typeName: 'Object', value: null}; + const variable: Variable = {name: newVariableName, typeName: 'Object', value: null, isArray: false}; this.modalDialogInstance = this.modalDialogService.show(() => , {actions: [this.saveAction]}) }; diff --git a/src/designer/elsa-workflows-designer/src/components/shared/modal-dialog/services.ts b/src/designer/elsa-workflows-designer/src/components/shared/modal-dialog/services.ts index 799e50440..2f4f1dd8e 100644 --- a/src/designer/elsa-workflows-designer/src/components/shared/modal-dialog/services.ts +++ b/src/designer/elsa-workflows-designer/src/components/shared/modal-dialog/services.ts @@ -1,8 +1,7 @@ import 'reflect-metadata'; import {Service} from "typedi"; import state from "./state"; -import {ModalActionDefinition, ModalDialogInstance, ShowModalDialogOptions} from "./models"; -import {ModalType} from "../../../components/shared/modal-dialog"; +import {ModalDialogInstance, ShowModalDialogOptions} from "./models"; @Service() export class ModalDialogService { diff --git a/src/designer/elsa-workflows-designer/src/models/core.ts b/src/designer/elsa-workflows-designer/src/models/core.ts index 48c2c8fbe..1a216c688 100644 --- a/src/designer/elsa-workflows-designer/src/models/core.ts +++ b/src/designer/elsa-workflows-designer/src/models/core.ts @@ -38,6 +38,8 @@ export interface Workflow extends Activity { export interface Variable { name: string; typeName: string; + + isArray: boolean; value?: any; storageDriverTypeName?: string; } diff --git a/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx b/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx index 90445db13..bc05f72dc 100644 --- a/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx +++ b/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx @@ -209,6 +209,11 @@ export class FlowchartComponent { this.updateLookups(); } + @Method() + async getActivity(id: string): Promise { + return this.activityLookup[id]; + } + @Method() public async renameActivity(args: RenameActivityArgs) { const nodeId = args.originalId; diff --git a/src/modules/Elsa.Expressions/Implementations/WellKnownTypeRegistry.cs b/src/modules/Elsa.Expressions/Implementations/WellKnownTypeRegistry.cs index 81bf2a2cb..b0cb84647 100644 --- a/src/modules/Elsa.Expressions/Implementations/WellKnownTypeRegistry.cs +++ b/src/modules/Elsa.Expressions/Implementations/WellKnownTypeRegistry.cs @@ -1,3 +1,4 @@ +using System.Dynamic; using Elsa.Expressions.Extensions; using Elsa.Expressions.Services; @@ -28,6 +29,7 @@ public class WellKnownTypeRegistry : IWellKnownTypeRegistry this.RegisterType("TimeSpan"); this.RegisterType("DateOnly"); this.RegisterType("TimeOnly"); + this.RegisterType("ExpandoObject"); } /// diff --git a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs index a3b7d6ae2..4eeb965ea 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs @@ -74,30 +74,42 @@ public class HttpEndpoint : Trigger protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { // If we did not receive external input, it means we are just now encountering this activity and we need to block execution by creating a bookmark. - if (!context.TryGetInput(HttpContextInputKey, out var httpContext)) + if (!context.TryGetInput(HttpContextInputKey, out var isHttpContext)) { // Create bookmarks for when we receive the expected HTTP request. context.CreateBookmarks(GetBookmarkPayloads(context.ExpressionExecutionContext)); return; } + + var httpContextAccessor = context.GetRequiredService(); + var httpContext = httpContextAccessor.HttpContext; - // Provide the received HTTP request as output. - var request = httpContext.Request; - context.Set(Result, request); - - // Read route data, if any. - var path = context.GetInput(RequestPathInputKey); - var routeData = GetRouteData(httpContext, path); - context.Set(RouteData, routeData); - - // Read content, if any. - var content = await ParseContentAsync(context, request); - context.Set(ParsedContent, content); - - // Complete. - await context.CompleteActivityAsync(); + if (httpContext == null) + { + // We're executing in a non-HTTP context (e.g. in a virtual actor). + // Create a bookmark to allow the invoker to export the state and resume execution from there. + + context.CreateBookmark(OnResumeAsync); + return; + } + + await HandleRequestAsync(context, httpContext); } + private async ValueTask OnResumeAsync(ActivityExecutionContext context) + { + var httpContextAccessor = context.GetRequiredService(); + var httpContext = httpContextAccessor.HttpContext; + + if (httpContext == null) + { + // We're not in an HTTP context, so let's fail. + throw new Exception("Cannot execute in a non-HTTP context"); + } + + await HandleRequestAsync(context, httpContext); + } + private async Task ParseContentAsync(ActivityExecutionContext context, HttpRequest httpRequest) { if (!HasContent(httpRequest)) @@ -121,6 +133,25 @@ public class HttpEndpoint : Trigger return methods!.Select(x => new HttpEndpointBookmarkPayload(path!, x.ToLowerInvariant())).Cast().ToArray(); } + private async Task HandleRequestAsync(ActivityExecutionContext context, HttpContext httpContext) + { + // Provide the received HTTP request as output. + var request = httpContext.Request; + context.Set(Result, request); + + // Read route data, if any. + var path = context.GetInput(RequestPathInputKey); + var routeData = GetRouteData(httpContext, path); + context.Set(RouteData, routeData); + + // Read content, if any. + var content = await ParseContentAsync(context, request); + context.Set(ParsedContent, content); + + // Complete. + await context.CompleteActivityAsync(); + } + private static RouteData GetRouteData(HttpContext httpContext, string path) { var routeData = httpContext.GetRouteData(); diff --git a/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs b/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs index eb8e310ff..ed2e53dfe 100644 --- a/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs +++ b/src/modules/Elsa.Http/Activities/WriteHttpResponse.cs @@ -59,17 +59,7 @@ public class WriteHttpResponse : CodeActivity return; } - var response = httpContext.Response; - response.StatusCode = (int)context.Get(StatusCode); - - var headers = ResponseHeaders.TryGet(context) ?? new HttpResponseHeaders(); - foreach (var header in headers) - response.Headers.Add(header.Key, header.Value); - - response.ContentType = ContentType.TryGet(context); - var content = context.Get(Content); - if (content != null) - await response.WriteAsync(content, context.CancellationToken); + await WriteResponseAsync(context, httpContext.Response); } private async ValueTask OnResumeAsync(ActivityExecutionContext context) @@ -83,10 +73,17 @@ public class WriteHttpResponse : CodeActivity throw new Exception("Cannot execute in a non-HTTP context"); } - var response = httpContext.Response; + await WriteResponseAsync(context, httpContext.Response); + } + private async Task WriteResponseAsync(ActivityExecutionContext context, HttpResponse response) + { response.StatusCode = (int)context.Get(StatusCode); + var headers = ResponseHeaders.TryGet(context) ?? new HttpResponseHeaders(); + foreach (var header in headers) + response.Headers.Add(header.Key, header.Value); + var content = context.Get(Content); if (content != null) diff --git a/src/modules/Elsa.Http/Extensions/RouteTableExtensions.cs b/src/modules/Elsa.Http/Extensions/RouteTableExtensions.cs index 6aab05bdc..feebd3601 100644 --- a/src/modules/Elsa.Http/Extensions/RouteTableExtensions.cs +++ b/src/modules/Elsa.Http/Extensions/RouteTableExtensions.cs @@ -60,8 +60,8 @@ public static class RouteTableExtensions routeTable.RemoveRange(paths); } - private static IEnumerable Filter(IEnumerable triggers) => triggers.Where(x => x.Name == ActivityTypeNameHelper.GenerateTypeName()); - private static IEnumerable Filter(IEnumerable triggers) => triggers.Where(x => x.Name == ActivityTypeNameHelper.GenerateTypeName()); + private static IEnumerable Filter(IEnumerable triggers) => triggers.Where(x => x.Name == ActivityTypeNameHelper.GenerateTypeName() && x.Data != null); + private static IEnumerable Filter(IEnumerable triggers) => triggers.Where(x => x.Name == ActivityTypeNameHelper.GenerateTypeName() && x.Data != null); private static HttpEndpointBookmarkPayload Deserialize(StoredTrigger trigger) => Deserialize(trigger.Data!); private static HttpEndpointBookmarkPayload Deserialize(Bookmark bookmark) => Deserialize(bookmark.Data!); private static HttpEndpointBookmarkPayload Deserialize(string model) => JsonSerializer.Deserialize(model, SerializerOptions)!; diff --git a/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs b/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs index 931e603ea..d07873e92 100644 --- a/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs +++ b/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs @@ -66,7 +66,7 @@ public class WorkflowsMiddleware var input = new Dictionary { - [HttpEndpoint.HttpContextInputKey] = httpContext, + [HttpEndpoint.HttpContextInputKey] = true, [HttpEndpoint.RequestPathInputKey] = path }; @@ -86,14 +86,15 @@ public class WorkflowsMiddleware cancellationToken); // We must assume that the workflow executed in a different process (when e.g. using Proto.Actor) - // and check if we received any `WriteHttpResponse` activity bookmarks. + // and check if we received any `HttpEndpoint` or `WriteHttpResponse` activity bookmarks. // If we did, acquire a lock on the workflow instance and resume it from here within an actual HTTP context so that the activity can complete its HTTP response. + var httpEndpointTypeName = ActivityTypeNameHelper.GenerateTypeName(); var writeHttpResponseTypeName = ActivityTypeNameHelper.GenerateTypeName(); var query = from triggeredWorkflow in triggerResult.TriggeredWorkflows from bookmark in triggeredWorkflow.Bookmarks - where bookmark.Name == writeHttpResponseTypeName + where bookmark.Name == writeHttpResponseTypeName || bookmark.Name == httpEndpointTypeName select (triggeredWorkflow.InstanceId, bookmark.Id); var workflowExecutionResults = new Stack<(string InstanceId, string BookmarkId)>(query); @@ -127,7 +128,7 @@ public class WorkflowsMiddleware cancellationToken); var workflowHost = await _workflowHostFactory.CreateAsync(workflow, workflowState, cancellationToken); - var options = new ResumeWorkflowHostOptions(correlationId, result.BookmarkId); + var options = new ResumeWorkflowHostOptions(correlationId, result.BookmarkId, Input: input); await workflowHost.ResumeWorkflowAsync(options, cancellationToken); // Import the updated workflow state into the runtime. diff --git a/src/modules/Elsa.Liquid/Implementations/LiquidTemplateManager.cs b/src/modules/Elsa.Liquid/Implementations/LiquidTemplateManager.cs index 29b1c4ec5..1bace432a 100644 --- a/src/modules/Elsa.Liquid/Implementations/LiquidTemplateManager.cs +++ b/src/modules/Elsa.Liquid/Implementations/LiquidTemplateManager.cs @@ -44,7 +44,13 @@ public class LiquidTemplateManager : ILiquidTemplateManager e => { if (!TryParse(source, out var parsed, out var error)) + { + error = "{% raw %}\n" + error + "\n{% endraw %}"; TryParse(error, out parsed, out error); + + e.SetSlidingExpiration(TimeSpan.FromMilliseconds(100)); + return parsed; + } // TODO: add signal based cache invalidation. e.SetSlidingExpiration(TimeSpan.FromSeconds(30)); diff --git a/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs b/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs index 5c65a5b46..796c47ff4 100644 --- a/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs +++ b/src/modules/Elsa.Workflows.Management/Mappers/VariableDefinitionMapper.cs @@ -31,11 +31,12 @@ public class VariableDefinitionMapper if (!_wellKnownTypeRegistry.TryGetTypeOrDefault(source.TypeName, out var type)) return null; - var variableGenericType = typeof(Variable<>).MakeGenericType(type); + var valueType = source.IsArray ? typeof(ICollection<>).MakeGenericType(type) : type; + var variableGenericType = typeof(Variable<>).MakeGenericType(valueType); var variable = (Variable)Activator.CreateInstance(variableGenericType)!; variable.Name = source.Name; - variable.Value = source.Value.ConvertTo(type); + variable.Value = source.Value.ConvertTo(valueType); variable.StorageDriverType = !string.IsNullOrEmpty(source.StorageDriverTypeName) ? Type.GetType(source.StorageDriverTypeName) : default; return variable; @@ -57,13 +58,16 @@ public class VariableDefinitionMapper public VariableDefinition Map(Variable source) { var variableType = source.GetType(); - var value = source.Value; var valueType = variableType.IsConstructedGenericType ? variableType.GetGenericArguments().FirstOrDefault() ?? typeof(object) : typeof(object); - var valueTypeAlias = _wellKnownTypeRegistry.GetAliasOrDefault(valueType); + var isArray = valueType.IsGenericType && typeof(ICollection<>).IsAssignableFrom(valueType.GetGenericTypeDefinition()); + var elementValueType = isArray ? valueType.GenericTypeArguments[0] : valueType; + var value = source.Value; + + var valueTypeAlias = _wellKnownTypeRegistry.GetAliasOrDefault(elementValueType); var storageDriverTypeName = source.StorageDriverType?.GetSimpleAssemblyQualifiedName(); var serializedValue = value.Format(); - return new VariableDefinition(source.Name, valueTypeAlias, serializedValue, storageDriverTypeName); + return new VariableDefinition(source.Name, valueTypeAlias, isArray, serializedValue, storageDriverTypeName); } /// diff --git a/src/modules/Elsa.Workflows.Management/Models/VariableDefinition.cs b/src/modules/Elsa.Workflows.Management/Models/VariableDefinition.cs index 293af427c..3173fdc3e 100644 --- a/src/modules/Elsa.Workflows.Management/Models/VariableDefinition.cs +++ b/src/modules/Elsa.Workflows.Management/Models/VariableDefinition.cs @@ -3,4 +3,4 @@ namespace Elsa.Workflows.Management.Models; /// /// Stores information about a workflow variable. /// -public record VariableDefinition(string Name, string TypeName, string? Value, string? StorageDriverTypeName); \ No newline at end of file +public record VariableDefinition(string Name, string TypeName, bool IsArray, string? Value, string? StorageDriverTypeName); \ No newline at end of file