Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Elsa.Api.Client.Resources.ActivityDescriptors.Models;
using Microsoft.AspNetCore.Components;

namespace Elsa.Studio.Workflows.Components.WorkflowDefinitionEditor.Components.ActivityProperties;

public record ActivityInputDisplayModel(RenderFragment Editor);
public record ActivityInputDisplayModel(RenderFragment Editor, InputDescriptor InputDescriptor);
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
<MudStack Spacing="5">
@foreach (var inputModel in InputDisplayModels)
{
@inputModel.Editor
if (IsVisibleInput(inputModel))
{
@inputModel.Editor
}
}
</MudStack>
</MudForm>
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ public InputsTab()
private ICollection<OutputDescriptor> OutputDescriptors { get; set; } = new List<OutputDescriptor>();
private ICollection<ActivityInputDisplayModel> InputDisplayModels { get; set; } = new List<ActivityInputDisplayModel>();

private List<string> _selectedStates = [];
private static Dictionary<string, JsonDocument> _InputDescriptors = new();
private static Dictionary<string, string> _previousStates = new();
Comment on lines +67 to +69
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsed JSON payloads are cached in static dictionaries. In a Blazor app this data will be shared across all component instances/users and will grow unbounded over time. Additionally, JsonDocument is IDisposable but stored without ever being disposed (and overwritten without disposing), which can cause memory leaks. Consider making these instance-scoped (non-static), using a bounded cache keyed to the current Activity/descriptor, and disposing documents when no longer needed (or deserialize into a POCO instead of keeping JsonDocument).

Copilot uses AI. Check for mistakes.

/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
Expand All @@ -73,20 +77,63 @@ protected override async Task OnParametersSetAsync()
InputDescriptors = ActivityDescriptor.Inputs.ToList();
OutputDescriptors = ActivityDescriptor.Outputs.ToList();
InputDisplayModels = (await BuildInputEditorModels(Activity, ActivityDescriptor, InputDescriptors)).ToList();
StateHasChanged();
Comment on lines 72 to +80
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_selectedStates is not reset when Activity/ActivityDescriptor changes. Switching between activities (or reloading descriptors) can leave stale state IDs in the list and cause ConditionalInput visibility to be evaluated against a previous activity’s state. Reset _selectedStates (and any per-activity previous-state tracking) when parameters change before rebuilding InputDisplayModels.

Copilot uses AI. Check for mistakes.
}

private async Task<IEnumerable<ActivityInputDisplayModel>> BuildInputEditorModels(JsonObject activity, ActivityDescriptor activityDescriptor, ICollection<InputDescriptor> inputDescriptors)
{
var models = new List<ActivityInputDisplayModel>();
var browsableInputDescriptors = inputDescriptors.Where(x => x.IsBrowsable == true).ToList();

foreach (var inputDescriptor in browsableInputDescriptors)
foreach (var inputDescriptor in inputDescriptors)
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildInputEditorModels no longer filters inputs by IsBrowsable. This will render inputs that the backend marked as non-browsable, potentially exposing internal/technical inputs in the UI. Reintroduce the IsBrowsable filter (or explain/implement an alternative rule for which inputs should be shown).

Suggested change
foreach (var inputDescriptor in inputDescriptors)
foreach (var inputDescriptor in inputDescriptors.Where(x => x.IsBrowsable))

Copilot uses AI. Check for mistakes.
{
var inputName = inputDescriptor.Name.Camelize();
var value = activity.GetProperty(inputName);
var wrappedInput = inputDescriptor.IsWrapped ? ToWrappedInput(value) : default;
var syntaxProvider = wrappedInput != null ? ExpressionDescriptorProvider.GetByType(wrappedInput.Expression.Type) : default;

// Check if we have a custom input
JsonDocument? InputDescriptor = null;
if (inputDescriptor.Description is not null)
{
SetInputDescriptor(inputDescriptor);
InputDescriptor = GetInputDescriptor(inputDescriptor);

if (InputDescriptor is not null)
{
string? inputType = InputDescriptor.RootElement.GetProperty("InputType").GetString();
// Check if we have conditional inputs
if (inputType == "StateDropdown")
{
if (wrappedInput is not null)
{
// Add the current value to the selected states
AddSelectedState(inputDescriptor, wrappedInput);
UpdateDescription(inputDescriptor, wrappedInput);
}
else if (wrappedInput is null && inputDescriptor.DefaultValue is not null)
{
// Add the default value to the selected states
AddSelectedState(inputDescriptor, inputDescriptor.DefaultValue);
UpdateDescription(inputDescriptor, inputDescriptor.DefaultValue);
Comment on lines +111 to +117
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For StateDropdown, UpdateDescription is called right after AddSelectedState, but AddSelectedState(WrappedInput) already calls UpdateDescription internally. This duplicates work and can lead to extra renders/side-effects. Remove one of the calls so description/state updates happen in a single place.

Suggested change
UpdateDescription(inputDescriptor, wrappedInput);
}
else if (wrappedInput is null && inputDescriptor.DefaultValue is not null)
{
// Add the default value to the selected states
AddSelectedState(inputDescriptor, inputDescriptor.DefaultValue);
UpdateDescription(inputDescriptor, inputDescriptor.DefaultValue);
}
else if (wrappedInput is null && inputDescriptor.DefaultValue is not null)
{
// Add the default value to the selected states
AddSelectedState(inputDescriptor, inputDescriptor.DefaultValue);

Copilot uses AI. Check for mistakes.
}
else if (wrappedInput is null || (wrappedInput is not null && string.IsNullOrEmpty(wrappedInput.Expression.ToString())))
{
// Empty state && InputDescriptor exists, therefore we hide the description
inputDescriptor.Description = "";
}

}
else if (inputType == "ConditionalInput")
{
inputDescriptor.Description = InputDescriptor.RootElement.GetProperty("Description").EnumerateArray().First().GetString();
}
else
{
inputDescriptor.Description = InputDescriptor.RootElement.GetProperty("Description").GetString();
}
Comment on lines +94 to +133
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After parsing Description as JSON, the code assumes properties like InputType/Description/Options/Ids always exist and uses GetProperty(), which will throw and break rendering if the JSON shape differs (or if some other feature stores valid JSON in Description). Prefer validating the schema with TryGetProperty (and expected ValueKind) before reading, and treat unrecognized shapes as a standard descriptor.

Copilot uses AI. Check for mistakes.
}
}

// Check if refresh is needed.
if (inputDescriptor.UISpecifications != null
&& inputDescriptor.UISpecifications.TryGetValue("Refresh", out var refreshInput)
Expand All @@ -109,17 +156,179 @@ private async Task<IEnumerable<ActivityInputDisplayModel>> BuildInputEditorModel
Value = input,
SelectedExpressionDescriptor = syntaxProvider,
UIHintHandler = uiHintHandler,
IsReadOnly = (Workspace?.IsReadOnly ?? false) || (inputDescriptor.IsReadOnly ?? false),
IsReadOnly = Workspace?.IsReadOnly ?? false
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsReadOnly no longer respects the input descriptor’s IsReadOnly flag, meaning inputs marked read-only by the server can become editable in the UI. Restore the previous logic that combines workspace read-only state with inputDescriptor.IsReadOnly.

Suggested change
IsReadOnly = Workspace?.IsReadOnly ?? false
IsReadOnly = (Workspace?.IsReadOnly ?? false) || inputDescriptor.IsReadOnly

Copilot uses AI. Check for mistakes.
};

context.OnValueChanged = async v => await HandleValueChangedAsync(context, v);
context.OnValueChanged = HandleValueChangedAsync(context, inputDescriptor);
var editor = uiHintHandler.DisplayInputEditor(context);
models.Add(new ActivityInputDisplayModel(editor));
models.Add(new ActivityInputDisplayModel(editor, inputDescriptor));
}

return models;
}

private string GetInputDescriptorKey(InputDescriptor inputDescriptor)
{
return ActivityDescriptor?.Name + inputDescriptor.Name;
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetInputDescriptorKey concatenates ActivityDescriptor.Name and inputDescriptor.Name without a delimiter. This can produce ambiguous keys (e.g., "AB"+"C" vs "A"+"BC") and cause collisions in the descriptor cache. Add an unambiguous separator (or use a tuple-like key) to avoid collisions.

Suggested change
return ActivityDescriptor?.Name + inputDescriptor.Name;
var activityName = ActivityDescriptor?.Name ?? string.Empty;
var inputName = inputDescriptor.Name ?? string.Empty;
return $"{activityName.Length}:{activityName}|{inputName.Length}:{inputName}";

Copilot uses AI. Check for mistakes.
}

private JsonDocument? GetInputDescriptor(InputDescriptor inputDescriptor)
{
return _InputDescriptors.GetValueOrDefault(GetInputDescriptorKey(inputDescriptor));
}

private void SetInputDescriptor(InputDescriptor inputDescriptor)
{
try
{
var InputDescriptor = JsonDocument.Parse(inputDescriptor.Description!);
_InputDescriptors[GetInputDescriptorKey(inputDescriptor)] = InputDescriptor;
}
catch
{
// Ignore --> Standard Elsa 3 descriptor
}
}

private void AddSelectedState(InputDescriptor inputDescriptor, WrappedInput? v)
{
var InputDescriptor = GetInputDescriptor(inputDescriptor);
if (v is null || InputDescriptor is null) return;

var valueAsString = v!.Expression.ToString();
AddSelectedState(inputDescriptor, valueAsString);
UpdateDescription(inputDescriptor, v);
}

private void AddSelectedState(InputDescriptor inputDescriptor, object? value)
{
if (value is null) return;

var InputDescriptor = GetInputDescriptor(inputDescriptor);
if (InputDescriptor is null) return;

var inputDescriptorKey = GetInputDescriptorKey(inputDescriptor);
// Remove the previous state
var previousState = _previousStates.GetValueOrDefault(inputDescriptorKey, string.Empty);
if (!string.IsNullOrEmpty(previousState))
{
RemoveSelectedState(previousState);
}

var valueAsString = value as string;
if (value is JsonElement)
{
valueAsString = ((JsonElement)value).GetString();
}

var stateNames = InputDescriptor.RootElement.GetProperty("Options").EnumerateArray();
var stateIds = InputDescriptor.RootElement.GetProperty("Ids").EnumerateArray();

for (var i = 0; i < stateNames.Count(); ++i)
{
var current = stateNames.ElementAt(i);
if (current.GetString() == valueAsString)
{
var id = stateIds.ElementAt(i).GetString()!;
Comment on lines +224 to +232
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddSelectedState iterates JsonElement arrays via LINQ Count()/ElementAt() on EnumerateArray(), which repeatedly enumerates and can become O(n^2). Consider materializing to an array/list once (or iterating with a manual index) and also validate that Options and Ids lengths match before indexing to avoid mismatches.

Suggested change
var stateNames = InputDescriptor.RootElement.GetProperty("Options").EnumerateArray();
var stateIds = InputDescriptor.RootElement.GetProperty("Ids").EnumerateArray();
for (var i = 0; i < stateNames.Count(); ++i)
{
var current = stateNames.ElementAt(i);
if (current.GetString() == valueAsString)
{
var id = stateIds.ElementAt(i).GetString()!;
var stateNamesElement = InputDescriptor.RootElement.GetProperty("Options");
var stateIdsElement = InputDescriptor.RootElement.GetProperty("Ids");
if (stateNamesElement.ValueKind != JsonValueKind.Array || stateIdsElement.ValueKind != JsonValueKind.Array)
{
StateHasChanged();
return;
}
var stateNamesCount = stateNamesElement.GetArrayLength();
var stateIdsCount = stateIdsElement.GetArrayLength();
if (stateNamesCount != stateIdsCount)
{
StateHasChanged();
return;
}
var stateNames = stateNamesElement.EnumerateArray();
var stateIds = stateIdsElement.EnumerateArray();
var stateNamesEnumerator = stateNames.GetEnumerator();
var stateIdsEnumerator = stateIds.GetEnumerator();
while (stateNamesEnumerator.MoveNext() && stateIdsEnumerator.MoveNext())
{
var current = stateNamesEnumerator.Current;
if (current.GetString() == valueAsString)
{
var id = stateIdsEnumerator.Current.GetString()!;

Copilot uses AI. Check for mistakes.

// Ensure that we have no duplicates
RemoveSelectedState(id);

_selectedStates.Add(id);
_previousStates[inputDescriptorKey] = id;
break;
}
}

StateHasChanged();
}
Comment on lines +227 to +244
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StateHasChanged() is called inside AddSelectedState(), which is invoked during BuildInputEditorModels() for each input. This can trigger a cascade of redundant renders during parameter setup and may cause flicker/perf issues. Avoid calling StateHasChanged in per-input setup code; instead update state and let the normal render cycle (or a single StateHasChanged after the loop) handle UI refresh.

Copilot uses AI. Check for mistakes.

private void RemoveSelectedState(string name)
{
_selectedStates.Remove(name);
}

private Func<object?, Task> HandleValueChangedAsync(DisplayInputEditorContext context, InputDescriptor inputDescriptor)
{
return async v =>
{
await HandleValueChangedAsync(context, v);

var InputDescriptor = GetInputDescriptor(inputDescriptor);
// Check if we have a custom input descriptor
if (InputDescriptor is null)
{
return;
}

// Update the selected states
if (InputDescriptor.RootElement.GetProperty("InputType").GetString() == "StateDropdown")
{
AddSelectedState(inputDescriptor, v as WrappedInput);
}
};
}

public void UpdateDescription(InputDescriptor inputDescriptor, object? value)
{
if (value is null)
{
return;
}

var valueAsString = "";
if (value is WrappedInput)
{
valueAsString = ((WrappedInput)value).Expression.ToString();
}
else if (value is JsonElement)
{
valueAsString = ((JsonElement)value).GetString();
}

var InputDescriptor = GetInputDescriptor(inputDescriptor)!;
// Update with the correct description
var options = InputDescriptor.RootElement.GetProperty("Options").EnumerateArray();
var descriptions = InputDescriptor.RootElement.GetProperty("Description").EnumerateArray();
var foundDescription = false;
for (var i = 0; i < options.Count(); ++i)
{
if (options.ElementAt(i).GetString() == valueAsString)
{
inputDescriptor.Description = descriptions.ElementAt(i).GetString();
foundDescription = true;
break;
}
}

if (!foundDescription)
{
inputDescriptor.Description = "";
}
}

public bool IsVisibleInput(ActivityInputDisplayModel model)
{

var InputDescriptor = GetInputDescriptor(model.InputDescriptor);

if (InputDescriptor == null) return true;

// Check we have a match for the selected state
if (InputDescriptor.RootElement.GetProperty("InputType").GetString() == "ConditionalInput")
{
var validStates = InputDescriptor.RootElement.GetProperty("ShowForStates");
foreach (var validState in validStates.EnumerateArray())
{
var value = validState.GetString();
if (value is not null && _selectedStates.Contains(value)) return true;
}
return false;
}

return true;
}

private async Task RefreshDescriptor(JsonObject activity, ActivityDescriptor activityDescriptor, IEnumerable<InputDescriptor> inputDescriptors, InputDescriptor currentInputDescriptor)
{
var activityTypeName = activityDescriptor.TypeName;
Expand Down