Skip to content

Commit

Permalink
Refactor workflow import process with enhanced result handling (#360)
Browse files Browse the repository at this point in the history
* Refactor workflow import process with enhanced result handling

Introduced detailed workflow import result tracking through new models, including success and failure differentiation. Enhanced feedback mechanisms in UI based on import outcomes, providing clear success, warning, and error messages. Streamlined `ImportFilesAsync` to return structured results, improving code maintainability and user experience.

* Update Snackbar behavior for workflow import results

Adjusted Snackbar messages to include a consistent variant style and dynamic visibility durations based on import failures. Enhanced feedback clarity by removing file names from messages and introducing a helper function to configure Snackbar options.

* Refactor workflow import logic for better readability.

Replaced redundant variables and improved method returns to avoid side effects and ensure clarity. Enhanced error handling and added filtering for unnecessary zip entries (e.g., "__MACOSX"). These changes improve maintainability and robustness of the import logic.
  • Loading branch information
sfmskywalker authored Jan 16, 2025
1 parent c0773bb commit 5478948
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 78 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Elsa.Studio.Workflows.Domain.Services;
using Elsa.Studio.Workflows.Domain.Models;
using Microsoft.AspNetCore.Components.Forms;

namespace Elsa.Studio.Workflows.Domain.Contracts;
Expand All @@ -11,5 +11,5 @@ public interface IWorkflowDefinitionImporter
/// <summary>
/// Imports a set of files containing workflow definitions.
/// </summary>
Task<IEnumerable<IBrowserFile>> ImportFilesAsync(IReadOnlyList<IBrowserFile> files, ImportOptions? options = null);
Task<IEnumerable<WorkflowImportResult>> ImportFilesAsync(IReadOnlyList<IBrowserFile> files, ImportOptions? options = null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Elsa.Api.Client.Resources.WorkflowDefinitions.Models;

namespace Elsa.Studio.Workflows.Domain.Models;

public class ImportOptions
{
public int MaxAllowedSize { get; set; } = 1024 * 1024 * 10; // 10 MB
public string? DefinitionId { get; set; }
public Func<WorkflowDefinition, Task>? ImportedCallback { get; set; }
public Func<Exception, Task> ErrorCallback { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Elsa.Studio.Workflows.Domain.Models;

public record WorkflowImportFailure(string ErrorMessage, WorkflowImportFailureType FailureType);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Elsa.Studio.Workflows.Domain.Models;

public enum WorkflowImportFailureType
{
Exception,
InvalidSchema
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Elsa.Api.Client.Resources.WorkflowDefinitions.Models;

namespace Elsa.Studio.Workflows.Domain.Models;

public class WorkflowImportResult
{
public string FileName { get; set; }
public WorkflowDefinition? WorkflowDefinition { get; set; }
public WorkflowImportFailure? Failure { get; set; }
public bool IsSuccess => Failure == null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Elsa.Api.Client.Resources.WorkflowDefinitions.Models;
using Elsa.Studio.Contracts;
using Elsa.Studio.Workflows.Domain.Contracts;
using Elsa.Studio.Workflows.Domain.Models;
using Elsa.Studio.Workflows.Domain.Notifications;
using Microsoft.AspNetCore.Components.Forms;

Expand All @@ -23,10 +24,10 @@ private async Task<IWorkflowDefinitionsApi> GetApiAsync(CancellationToken cancel
}

/// <inheritdoc />
public async Task<IEnumerable<IBrowserFile>> ImportFilesAsync(IReadOnlyList<IBrowserFile> files, ImportOptions? options = null)
public async Task<IEnumerable<WorkflowImportResult>> ImportFilesAsync(IReadOnlyList<IBrowserFile> files, ImportOptions? options = null)
{
var importedFiles = new List<IBrowserFile>();
var maxAllowedSize = options?.MaxAllowedSize ?? 1024 * 1024 * 10; // 10 MB
var results = new List<WorkflowImportResult>();

foreach (var file in files)
{
Expand All @@ -35,60 +36,63 @@ public async Task<IEnumerable<IBrowserFile>> ImportFilesAsync(IReadOnlyList<IBro

if (file.ContentType == MediaTypeNames.Application.Zip || file.Name.EndsWith(".zip"))
{
var success = await ImportZipFileAsync(stream, options);
if (success)
{
importedFiles.Add(file);
await mediator.NotifyAsync(new ImportedFile(file));
}
var importZipFileResults = await ImportZipFileAsync(stream, options);
results.AddRange(importZipFileResults);
await mediator.NotifyAsync(new ImportedFile(file));
}

else if (file.ContentType == MediaTypeNames.Application.Json || file.Name.EndsWith(".json"))
{
var success = await ImportFromStreamAsync(stream, options);
if (success)
{
importedFiles.Add(file);
await mediator.NotifyAsync(new ImportedFile(file));
}
var importFromStreamResult = await ImportFromStreamAsync(file.Name, stream, options);
results.Add(importFromStreamResult);
await mediator.NotifyAsync(new ImportedFile(file));
}
}

return importedFiles;
return results;
}
private async Task<bool> ImportZipFileAsync(Stream stream, ImportOptions? options)

private async Task<IList<WorkflowImportResult>> ImportZipFileAsync(Stream stream, ImportOptions? options)
{
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream);
memoryStream.Seek(0, SeekOrigin.Begin);
var zipArchive = new ZipArchive(memoryStream);
var hasErrors = false;
var importResultList = new List<WorkflowImportResult>();

foreach (var entry in zipArchive.Entries)
try
{
if (entry.FullName.EndsWith(".json"))
var zipArchive = new ZipArchive(memoryStream);
foreach (var entry in zipArchive.Entries.Where(x => !x.FullName.StartsWith("__MACOSX", StringComparison.OrdinalIgnoreCase)))
{
await using var entryStream = entry.Open();
var success = await ImportFromStreamAsync(entryStream, options);
if (entry.FullName.EndsWith(".json"))
{
await using var entryStream = entry.Open();
var result = await ImportFromStreamAsync(entry.Name, entryStream, options);

if (!success)
hasErrors = true;
importResultList.Add(result);
}
else if (entry.FullName.EndsWith(".zip"))
{
await using var entryStream = entry.Open();
var results = await ImportZipFileAsync(entryStream, options);
importResultList.AddRange(results);
}
}
else if (entry.FullName.EndsWith(".zip"))
}
catch (Exception e)
{
if (options?.ErrorCallback != null)
await options.ErrorCallback(e);
importResultList.Add(new()
{
await using var entryStream = entry.Open();
var success = await ImportZipFileAsync(entryStream, options);

if (!success)
hasErrors = true;
}
Failure = new(e.Message, WorkflowImportFailureType.Exception)
});
}

return !hasErrors;
return importResultList;
}

private async Task<bool> ImportFromStreamAsync(Stream stream, ImportOptions? options)
private async Task<WorkflowImportResult> ImportFromStreamAsync(string fileName, Stream stream, ImportOptions? options)
{
using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync();
Expand All @@ -97,35 +101,49 @@ private async Task<bool> ImportFromStreamAsync(Stream stream, ImportOptions? opt
try
{
await mediator.NotifyAsync(new ImportingJson(json));

if(!workflowJsonDetector.IsWorkflowSchema(json))
return true;


if (!workflowJsonDetector.IsWorkflowSchema(json))
{
return new()
{
FileName = fileName,
Failure = new("Invalid schema", WorkflowImportFailureType.InvalidSchema)
};
}

var model = JsonSerializer.Deserialize<WorkflowDefinitionModel>(json, jsonSerializerOptions)!;
if(options?.DefinitionId != null)

if (options?.DefinitionId != null)
model.DefinitionId = options.DefinitionId;

await mediator.NotifyAsync(new ImportingWorkflowDefinition(model));
var api = await GetApiAsync();
var newWorkflowDefinition = await api.ImportAsync(model);
if(options?.ImportedCallback != null)

if (options?.ImportedCallback != null)
await options.ImportedCallback(newWorkflowDefinition);

await mediator.NotifyAsync(new ImportedWorkflowDefinition(newWorkflowDefinition));
await mediator.NotifyAsync(new ImportedJson(json));

return new()
{
FileName = fileName,
WorkflowDefinition = newWorkflowDefinition
};
}
catch (Exception e)
{
if(options?.ErrorCallback != null)
if (options?.ErrorCallback != null)
await options.ErrorCallback(e);
return false;
return new()
{
FileName = fileName,
Failure = new(e.Message, WorkflowImportFailureType.Exception)
};
}

return true;
}

private static JsonSerializerOptions CreateJsonSerializerOptions()
{
JsonSerializerOptions options = new()
Expand All @@ -137,12 +155,4 @@ private static JsonSerializerOptions CreateJsonSerializerOptions()
options.Converters.Add(new VersionOptionsJsonConverter());
return options;
}
}

public class ImportOptions
{
public int MaxAllowedSize { get; set; } = 1024 * 1024 * 10; // 10 MB
public string? DefinitionId { get; set; }
public Func<WorkflowDefinition, Task>? ImportedCallback { get; set; }
public Func<Exception, Task> ErrorCallback { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
using Elsa.Studio.Workflows.Components.WorkflowDefinitionEditor.Components.ActivityProperties;
using Elsa.Studio.Workflows.Domain.Contracts;
using Elsa.Studio.Workflows.Domain.Models;
using Elsa.Studio.Workflows.Domain.Services;
using Elsa.Studio.Workflows.Models;
using Elsa.Studio.Workflows.Shared.Components;
using Elsa.Studio.Workflows.UI.Contracts;
Expand All @@ -23,7 +22,7 @@
using Radzen;
using Radzen.Blazor;
using ThrottleDebounce;
using static MudBlazor.Colors;
using Variant = MudBlazor.Variant;

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

Expand Down Expand Up @@ -57,7 +56,7 @@ public WorkflowEditor()

/// Gets the selected activity ID.
public string? SelectedActivityId { get; private set; }

[Inject] private IWorkflowDefinitionEditorService WorkflowDefinitionEditorService { get; set; } = null!;
[Inject] private IWorkflowDefinitionImporter WorkflowDefinitionImporter { get; set; } = null!;
[Inject] private IActivityVisitor ActivityVisitor { get; set; } = null!;
Expand Down Expand Up @@ -219,7 +218,7 @@ await result.OnFailedAsync(errors =>
}
});
}

private void SelectActivity(JsonObject activity)
{
// Setting the activity to null first and then requesting an update is a workaround to ensure that BlazorMonaco gets destroyed first.
Expand Down Expand Up @@ -368,17 +367,36 @@ private async Task ImportFilesAsync(IReadOnlyList<IBrowserFile> files)
return Task.CompletedTask;
}
};
var importedFiles = (await WorkflowDefinitionImporter.ImportFilesAsync(files, options)).ToList();
var importResults = (await WorkflowDefinitionImporter.ImportFilesAsync(files, options)).ToList();
var failedImports = importResults.Where(x => !x.IsSuccess).ToList();
var successfulImports = importResults.Where(x => x.IsSuccess).ToList();

IsProgressing = false;
_isDirty = false;
StateHasChanged();

if (importedFiles.Count == 0)
Snackbar.Add("No files were imported.", Severity.Warning);
else if (importedFiles.Count == 1)
Snackbar.Add($"Successfully imported workflow definition from file {importedFiles[0].Name}.", Severity.Success);
else if (importedFiles.Count > 1)
Snackbar.Add($"Successfully imported {importedFiles.Count} files.", Severity.Success);
if (importResults.Count == 0)
{
Snackbar.Add("No workflows were imported.", Severity.Info);
return;
}

if (successfulImports.Count == 1)
Snackbar.Add($"Successfully imported 1 workflow definition.", Severity.Success, ConfigureSnackbar);
else if (importResults.Count > 1)
Snackbar.Add($"Successfully imported {importResults.Count} workflow definitions.", Severity.Success, ConfigureSnackbar);

if (failedImports.Count == 1)
Snackbar.Add($"Failed to import 1 workflow definition: {failedImports[0].Failure!.ErrorMessage}", Severity.Error, ConfigureSnackbar);
else if (failedImports.Count > 1)
Snackbar.Add($"Failed to import {failedImports.Count} workflow definitions. Errors: {string.Join(", ", failedImports.Select(x => x.Failure!.ErrorMessage))}", Severity.Error, ConfigureSnackbar);

return;
void ConfigureSnackbar(SnackbarOptions snackbarOptions)
{
snackbarOptions.SnackbarVariant = Variant.Filled;
snackbarOptions.CloseAfterNavigation = failedImports.Count > 0;
snackbarOptions.VisibleStateDuration = failedImports.Count > 0 ? 10000 : 3000;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public partial class WorkflowDefinitionList
private string SearchTerm { get; set; } = string.Empty;
private bool IsReadOnlyMode { get; set; }
private const string ReadonlyWorkflowsExcluded = "The read-only workflows will not be affected.";

private async Task<TableData<WorkflowDefinitionRow>> ServerReload(TableState state, CancellationToken cancellationToken)
{
var request = new ListWorkflowDefinitionsRequest
Expand Down Expand Up @@ -163,12 +163,12 @@ private async Task OnRunWorkflowClicked(WorkflowDefinitionRow workflowDefinition
var definitionId = workflowDefinitionRow!.DefinitionId;
var response = await WorkflowDefinitionService.ExecuteAsync(definitionId, request);

if(response.CannotStart)
if (response.CannotStart)
{
Snackbar.Add("The workflow cannot be started", Severity.Error);
return;
}

Snackbar.Add("Successfully started workflow", Severity.Success);
}

Expand Down Expand Up @@ -324,9 +324,21 @@ private Task OnImportClicked()

private async Task OnFilesSelected(IReadOnlyList<IBrowserFile> files)
{
var importedFiles = (await WorkflowDefinitionImporter.ImportFilesAsync(files)).ToList();
var message = importedFiles.Count == 1 ? "Successfully imported one workflow" : $"Successfully imported {importedFiles.Count} workflows";
Snackbar.Add(message, Severity.Success, options => { options.SnackbarVariant = Variant.Filled; });
var results = (await WorkflowDefinitionImporter.ImportFilesAsync(files)).ToList();
var successfulResultCount = results.Count(x => x.IsSuccess);
var failedResultCount = results.Count(x => !x.IsSuccess);
var successfulWorkflowsTerm = successfulResultCount == 1 ? "workflow" : "workflows";
var failedWorkflowsTerm = failedResultCount == 1 ? "workflow" : "workflows";
var message = results.Count == 0 ? "No workflows found to import." :
successfulResultCount > 0 && failedResultCount == 0 ? $"{successfulResultCount} {successfulWorkflowsTerm} imported successfully." :
successfulResultCount == 0 && failedResultCount > 0 ? $"Failed to import {failedResultCount} {failedWorkflowsTerm}." : $"{successfulResultCount} {successfulWorkflowsTerm} imported successfully. {failedResultCount} {failedWorkflowsTerm} failed to import.";
var severity = results.Count == 0 ? Severity.Info : successfulResultCount > 0 && failedResultCount > 0 ? Severity.Warning : failedResultCount == 0 ? Severity.Success : Severity.Error;
Snackbar.Add(message, severity, options =>
{
options.SnackbarVariant = Variant.Filled;
options.CloseAfterNavigation = failedResultCount > 0;
options.VisibleStateDuration = failedResultCount > 0 ? 10000 : 3000;
});
Reload();
}

Expand Down Expand Up @@ -382,4 +394,4 @@ private record WorkflowDefinitionRow(
string? Description,
bool IsPublished,
bool IsReadOnlyMode);
}
}

0 comments on commit 5478948

Please sign in to comment.