From 9b513e666680f0a9ed2da10a4f112e7dfaa103ea Mon Sep 17 00:00:00 2001 From: ndigirigijohn Date: Wed, 12 Feb 2025 08:47:32 +0300 Subject: [PATCH 1/6] extended workflow running to handle form triggered workflow --- .../Domain/Enums/EWorkflowState.cs | 2 + .../Components/Pages/Designer.razor | 26 ++--- .../Components/Pages/Workflow.razor | 107 ++++++++---------- 3 files changed, 58 insertions(+), 77 deletions(-) diff --git a/Blocktrust.CredentialWorkflow.Core/Domain/Enums/EWorkflowState.cs b/Blocktrust.CredentialWorkflow.Core/Domain/Enums/EWorkflowState.cs index 4668422..e847471 100644 --- a/Blocktrust.CredentialWorkflow.Core/Domain/Enums/EWorkflowState.cs +++ b/Blocktrust.CredentialWorkflow.Core/Domain/Enums/EWorkflowState.cs @@ -5,4 +5,6 @@ public enum EWorkflowState Inactive, ActiveWithExternalTrigger, ActiveWithRecurrentTrigger, + ActiveWithFormTrigger + } \ No newline at end of file diff --git a/Blocktrust.CredentialWorkflow.Web/Components/Pages/Designer.razor b/Blocktrust.CredentialWorkflow.Web/Components/Pages/Designer.razor index 01bb286..c5d5598 100644 --- a/Blocktrust.CredentialWorkflow.Web/Components/Pages/Designer.razor +++ b/Blocktrust.CredentialWorkflow.Web/Components/Pages/Designer.razor @@ -280,21 +280,13 @@ else if (currentWorkflow.WorkflowState == EWorkflowState.Inactive) { var triggerType = currentWorkflow.ProcessFlow.Triggers.First().Value.Type; - EWorkflowState state = EWorkflowState.ActiveWithExternalTrigger; - if (triggerType == ETriggerType.HttpRequest) + EWorkflowState state = triggerType switch { - state = EWorkflowState.ActiveWithExternalTrigger; - } - else if (triggerType == ETriggerType.RecurringTimer) - { - state = EWorkflowState.ActiveWithRecurrentTrigger; - } - else - { - errorMessage = "Failed to run the workflow. Unsupported trigger type."; - await ShowToast(errorMessage); - return; - } + ETriggerType.HttpRequest => EWorkflowState.ActiveWithExternalTrigger, + ETriggerType.RecurringTimer => EWorkflowState.ActiveWithRecurrentTrigger, + ETriggerType.Form => EWorkflowState.ActiveWithFormTrigger, + _ => throw new InvalidOperationException($"Unsupported trigger type: {triggerType}") + }; var changeStateResult = await Mediator.Send(new ChangeWorkflowStateRequest(currentWorkflow.WorkflowId, state)); if (changeStateResult.IsFailed) @@ -303,7 +295,6 @@ else await ShowToast(errorMessage); return; } - currentWorkflow.WorkflowState = state; } else @@ -311,19 +302,16 @@ else var changeStateResult = await Mediator.Send(new ChangeWorkflowStateRequest(currentWorkflow.WorkflowId, EWorkflowState.Inactive)); if (changeStateResult.IsFailed) { - errorMessage = "Failed to run the workflow. Please try again."; + errorMessage = "Failed to stop the workflow. Please try again."; await ShowToast(errorMessage); return; } - currentWorkflow.WorkflowState = EWorkflowState.Inactive; } - StateHasChanged(); } } } - private void HandleItemSelected(object? item) { selectedItem = item; diff --git a/Blocktrust.CredentialWorkflow.Web/Components/Pages/Workflow.razor b/Blocktrust.CredentialWorkflow.Web/Components/Pages/Workflow.razor index f17a3ed..deda7c4 100644 --- a/Blocktrust.CredentialWorkflow.Web/Components/Pages/Workflow.razor +++ b/Blocktrust.CredentialWorkflow.Web/Components/Pages/Workflow.razor @@ -280,10 +280,11 @@ { EWorkflowState.ActiveWithExternalTrigger => "bg-green-100 text-green-800", EWorkflowState.ActiveWithRecurrentTrigger => "bg-green-100 text-green-800", + EWorkflowState.ActiveWithFormTrigger => "bg-green-100 text-green-800", EWorkflowState.Inactive => "bg-gray-100 text-gray-800", _ => "bg-gray-100 text-gray-800" }; - + private string GetOutcomeStateColor(EWorkflowOutcomeState state) => state switch { EWorkflowOutcomeState.Success => "bg-green-100 text-green-800", @@ -419,72 +420,62 @@ } } - private async Task ToggleWorkflowState(WorkflowSummary workflow) +private async Task ToggleWorkflowState(WorkflowSummary workflow) +{ + try { - try + if (workflow.WorkflowState == EWorkflowState.Inactive) { - if (workflow.WorkflowState == EWorkflowState.Inactive) + var completeWorkflow = await Mediator.Send(new GetWorkflowByIdRequest(workflow.WorkflowId)); + if (completeWorkflow.IsFailed) + { + errorMessage = "Failed to run the workflow. Please try again."; + return; + } + + if (completeWorkflow.Value.ProcessFlow is null || !completeWorkflow.Value.ProcessFlow.Triggers.Any()) + { + errorMessage = "Workflow has no triggers. Please add a trigger before running."; + return; + } + + var firstTriggerType = completeWorkflow.Value.ProcessFlow.Triggers.First().Value.Type; + EWorkflowState newState = firstTriggerType switch + { + ETriggerType.HttpRequest => EWorkflowState.ActiveWithExternalTrigger, + ETriggerType.RecurringTimer => EWorkflowState.ActiveWithRecurrentTrigger, + ETriggerType.Form => EWorkflowState.ActiveWithFormTrigger, + _ => throw new InvalidOperationException($"Unsupported trigger type: {firstTriggerType}") + }; + + var result = await Mediator.Send(new ChangeWorkflowStateRequest(workflow.WorkflowId, newState)); + if (result.IsFailed) { - var completeWorkflow = await Mediator.Send(new GetWorkflowByIdRequest(workflow.WorkflowId)); - if (completeWorkflow.IsFailed) - { - errorMessage = "Failed to run the workflow. Please try again."; - return; - } - - if (completeWorkflow.Value.ProcessFlow is null || !completeWorkflow.Value.ProcessFlow.Triggers.Any()) - { - errorMessage = "Workflow has no triggers. Please add a trigger before running."; - return; - } - - var firstTriggerType = completeWorkflow.Value.ProcessFlow.Triggers.First().Value.Type; - EWorkflowState newState; - switch (firstTriggerType) - { - case ETriggerType.HttpRequest: - newState = EWorkflowState.ActiveWithExternalTrigger; - break; - case ETriggerType.RecurringTimer: - newState = EWorkflowState.ActiveWithRecurrentTrigger; - break; - default: - errorMessage = "Failed to run the workflow. Unsupported trigger type."; - StateHasChanged(); - return; - } - - var result = await Mediator.Send(new ChangeWorkflowStateRequest(workflow.WorkflowId, newState)); - if (result.IsFailed) - { - errorMessage = "Failed to run the workflow. Please try again."; - } - else - { - // Update the local state so the table refreshes correctly - workflow.WorkflowState = newState; - } + errorMessage = "Failed to run the workflow. Please try again."; } else { - // "Stop" the workflow - var result = await Mediator.Send(new ChangeWorkflowStateRequest(workflow.WorkflowId, EWorkflowState.Inactive)); - if (result.IsFailed) - { - errorMessage = "Failed to stop the workflow. Please try again."; - } - else - { - workflow.WorkflowState = EWorkflowState.Inactive; - } + workflow.WorkflowState = newState; } } - catch (Exception ex) + else { - errorMessage = $"An error occurred: {ex.Message}"; + var result = await Mediator.Send(new ChangeWorkflowStateRequest(workflow.WorkflowId, EWorkflowState.Inactive)); + if (result.IsFailed) + { + errorMessage = "Failed to stop the workflow. Please try again."; + } + else + { + workflow.WorkflowState = EWorkflowState.Inactive; + } } - - // Re-render to show new state or error - StateHasChanged(); } + catch (Exception ex) + { + errorMessage = $"An error occurred: {ex.Message}"; + } + StateHasChanged(); +} + } \ No newline at end of file From 7446bae56c1c43c41a8cbb1580055294f6d264e9 Mon Sep 17 00:00:00 2001 From: ndigirigijohn Date: Wed, 12 Feb 2025 09:00:28 +0300 Subject: [PATCH 2/6] fixed dynamic form binding on dynamic form razor --- .../Components/Pages/DynamicForm.razor | 338 +++++++----------- .../wwwroot/app.css | 82 ++--- 2 files changed, 177 insertions(+), 243 deletions(-) diff --git a/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor b/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor index ab8884a..3a03e9b 100644 --- a/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor +++ b/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor @@ -9,7 +9,6 @@ @inject NavigationManager NavigationManager @inject ILogger Logger @inject IFormService FormService - @attribute [AllowAnonymous] Workflow Form @@ -33,134 +32,80 @@ } - else + else if (!string.IsNullOrEmpty(successMessage)) { - @if (!string.IsNullOrEmpty(successMessage)) - { -
-
-
-

@successMessage

-
+
+
+
+

@successMessage

- } - else - { - - - - @foreach (var param in parameters) - { -
- - - @{ - var fieldModel = new FieldModel - { - Value = formModel[param.Name], - OnValueChanged = val => - { - formModel[param.Name] = val; - StateHasChanged(); - } - }; - } - - @switch (param.Type.ToLower()) - { - case "string": - case "email": - - break; - case "number": - - break; - case "boolean": - - break; - case "date": - - break; - } - -
- } - -
- +
+ } + else + { + + + + @foreach (var field in formModel.Fields) + { +
+ + + @switch (field.Type.ToLower()) + { + case "string": + case "email": + + break; + + case "number": + + break; + + case "boolean": + + break; + + case "date": + + break; + } + + @if (!string.IsNullOrEmpty(field.ValidationMessage)) + { +
@field.ValidationMessage
+ }
-
- } + } + +
+ +
+
}
@code { - [Parameter] public Guid WorkflowId { get; set; } + [Parameter] + public Guid WorkflowId { get; set; } private bool isLoading = true; private string? error; private string? successMessage; - private List<(string Name, string Type, string? Description)> parameters = new(); private DynamicFormModel formModel = new(); - private class FieldModel - { - private object _value; - private Action _onValueChanged; - - public object Value - { - get => _value; - set - { - _value = value; - _onValueChanged?.Invoke(value); - } - } - - public Action OnValueChanged - { - get => _onValueChanged; - set => _onValueChanged = value; - } - - public string StringValue - { - get => (string)(_value ?? string.Empty); - set => Value = value; - } - - public decimal DecimalValue - { - get => _value == null ? 0 : Convert.ToDecimal(_value); - set => Value = value; - } - - public bool BoolValue - { - get => _value != null && Convert.ToBoolean(_value); - set => Value = value; - } - - public DateTime DateValue - { - get => _value == null ? DateTime.Today : Convert.ToDateTime(_value); - set => Value = value; - } - } - protected override async Task OnInitializedAsync() { try @@ -190,8 +135,13 @@ { foreach (var param in formTrigger.Parameters) { - parameters.Add((param.Key, param.Value.Type.ToString().ToLower(), param.Value.Description)); - formModel[param.Key] = GetDefaultValueForParameter(param.Value.Type); + formModel.Fields.Add(new FormField + { + Name = param.Key, + Type = param.Value.Type.ToString().ToLower(), + Description = param.Value.Description, + IsRequired = param.Value.Required + }); } } } @@ -206,36 +156,28 @@ } } - private object GetDefaultValueForParameter(ParameterType type) - { - return type switch - { - ParameterType.String => string.Empty, - ParameterType.Number => 0m, - ParameterType.Boolean => false, - ParameterType.Date => DateTime.Today, - _ => string.Empty - }; - } - private async Task HandleSubmit() { try { + if (!ValidateForm()) + { + return; + } + isLoading = true; StateHasChanged(); - // Convert form data to object dictionary - var formData = formModel.ToDictionary( - kvp => kvp.Key, - kvp => kvp.Value + var formData = formModel.Fields.ToDictionary( + field => field.Name, + field => field.GetValue() ); var result = await FormService.ProcessFormSubmission(WorkflowId, formData); - if (result.IsSuccess) { successMessage = "Form submitted successfully!"; + formModel = new DynamicFormModel(); // Reset form } else { @@ -254,96 +196,88 @@ } } - public class DynamicFormModel : Dictionary + private bool ValidateForm() { - public new object this[string key] + bool isValid = true; + foreach (var field in formModel.Fields) { - get => ContainsKey(key) ? base[key] : null; - set => base[key] = value; + if (!field.Validate()) + { + isValid = false; + } } + return isValid; + } - // Custom validation attributes - public class RequiredPropertyAttribute : ValidationAttribute - { - private readonly string _propertyName; + public class DynamicFormModel + { + public List Fields { get; set; } = new(); + } - public RequiredPropertyAttribute(string propertyName) - { - _propertyName = propertyName; - } + public class FormField + { + public string Name { get; set; } = ""; + public string Type { get; set; } = "string"; + public string? Description { get; set; } + public bool IsRequired { get; set; } + public string? ValidationMessage { get; set; } + + public string StringValue { get; set; } = ""; + public decimal NumberValue { get; set; } + public bool BoolValue { get; set; } + public DateTime DateValue { get; set; } = DateTime.Today; + + public bool Validate() + { + ValidationMessage = null; - protected override ValidationResult IsValid(object value, ValidationContext validationContext) + if (IsRequired) { - var model = (DynamicFormModel)validationContext.ObjectInstance; - var propertyValue = model[_propertyName]; - - if (propertyValue == null || (propertyValue is string str && string.IsNullOrWhiteSpace(str))) + switch (Type.ToLower()) { - return new ValidationResult($"The {_propertyName} field is required."); + case "string": + case "email": + if (string.IsNullOrWhiteSpace(StringValue)) + { + ValidationMessage = $"{Name} is required."; + return false; + } + if (Type.ToLower() == "email" && !IsValidEmail(StringValue)) + { + ValidationMessage = $"{Name} must be a valid email address."; + return false; + } + break; } - - return ValidationResult.Success; } + + return true; } - public class EmailValidationAttribute : ValidationAttribute + private bool IsValidEmail(string email) { - private readonly string _propertyName; - - public EmailValidationAttribute(string propertyName) + try { - _propertyName = propertyName; + var addr = new System.Net.Mail.MailAddress(email); + return addr.Address == email; } - - protected override ValidationResult IsValid(object value, ValidationContext validationContext) + catch { - var model = (DynamicFormModel)validationContext.ObjectInstance; - var propertyValue = model[_propertyName]?.ToString(); - - if (string.IsNullOrEmpty(propertyValue)) - { - return ValidationResult.Success; - } - - try - { - var addr = new System.Net.Mail.MailAddress(propertyValue); - return addr.Address == propertyValue ? ValidationResult.Success - : new ValidationResult($"The {_propertyName} field is not a valid email address."); - } - catch - { - return new ValidationResult($"The {_propertyName} field is not a valid email address."); - } + return false; } } - public class DateValidationAttribute : ValidationAttribute + public object GetValue() { - private readonly string _propertyName; - - public DateValidationAttribute(string propertyName) + return Type.ToLower() switch { - _propertyName = propertyName; - } - - protected override ValidationResult IsValid(object value, ValidationContext validationContext) - { - var model = (DynamicFormModel)validationContext.ObjectInstance; - var propertyValue = model[_propertyName]?.ToString(); - - if (string.IsNullOrEmpty(propertyValue)) - { - return ValidationResult.Success; - } - - if (!DateTime.TryParse(propertyValue, out _)) - { - return new ValidationResult($"The {_propertyName} field must be a valid date."); - } - - return ValidationResult.Success; - } + "string" => StringValue, + "email" => StringValue, + "number" => NumberValue, + "boolean" => BoolValue, + "date" => DateValue, + _ => StringValue + }; } } } \ No newline at end of file diff --git a/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css b/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css index aa60cbb..3c25284 100644 --- a/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css +++ b/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css @@ -713,6 +713,10 @@ video { margin-left: 0.5rem; } +.ml-3 { + margin-left: 0.75rem; +} + .ml-4 { margin-left: 1rem; } @@ -773,10 +777,6 @@ video { margin-top: 2rem; } -.ml-3 { - margin-left: 0.75rem; -} - .block { display: block; } @@ -813,6 +813,10 @@ video { height: 8rem; } +.h-4 { + height: 1rem; +} + .h-40 { height: 10rem; } @@ -833,10 +837,6 @@ video { height: 100vh; } -.h-4 { - height: 1rem; -} - .max-h-24 { max-height: 6rem; } @@ -893,6 +893,10 @@ video { width: 8rem; } +.w-4 { + width: 1rem; +} + .w-56 { width: 14rem; } @@ -917,10 +921,6 @@ video { width: 100%; } -.w-4 { - width: 1rem; -} - .min-w-0 { min-width: 0px; } @@ -1237,6 +1237,16 @@ video { border-color: rgb(55 65 81 / var(--tw-border-opacity)); } +.border-green-400 { + --tw-border-opacity: 1; + border-color: rgb(74 222 128 / var(--tw-border-opacity)); +} + +.border-red-400 { + --tw-border-opacity: 1; + border-color: rgb(248 113 113 / var(--tw-border-opacity)); +} + .border-red-500 { --tw-border-opacity: 1; border-color: rgb(239 68 68 / var(--tw-border-opacity)); @@ -1272,16 +1282,6 @@ video { border-color: rgb(15 23 42 / var(--tw-border-opacity)); } -.border-green-400 { - --tw-border-opacity: 1; - border-color: rgb(74 222 128 / var(--tw-border-opacity)); -} - -.border-red-400 { - --tw-border-opacity: 1; - border-color: rgb(248 113 113 / var(--tw-border-opacity)); -} - .border-transparent { border-color: transparent; } @@ -1306,6 +1306,11 @@ video { background-color: rgb(243 244 246 / var(--tw-bg-opacity)); } +.bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity)); +} + .bg-gray-50 { --tw-bg-opacity: 1; background-color: rgb(249 250 251 / var(--tw-bg-opacity)); @@ -1326,6 +1331,11 @@ video { background-color: rgb(220 252 231 / var(--tw-bg-opacity)); } +.bg-green-50 { + --tw-bg-opacity: 1; + background-color: rgb(240 253 244 / var(--tw-bg-opacity)); +} + .bg-green-500 { --tw-bg-opacity: 1; background-color: rgb(34 197 94 / var(--tw-bg-opacity)); @@ -1406,16 +1416,6 @@ video { background-color: rgb(254 249 195 / var(--tw-bg-opacity)); } -.bg-gray-200 { - --tw-bg-opacity: 1; - background-color: rgb(229 231 235 / var(--tw-bg-opacity)); -} - -.bg-green-50 { - --tw-bg-opacity: 1; - background-color: rgb(240 253 244 / var(--tw-bg-opacity)); -} - .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -1512,6 +1512,11 @@ video { padding-bottom: 0.25rem; } +.py-12 { + padding-top: 3rem; + padding-bottom: 3rem; +} + .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; @@ -1532,11 +1537,6 @@ video { padding-bottom: 2rem; } -.py-12 { - padding-top: 3rem; - padding-bottom: 3rem; -} - .pl-4 { padding-left: 1rem; } @@ -2040,15 +2040,15 @@ a { outline-offset: 2px; } -.focus\:ring-2:focus { +.focus\:ring-1:focus { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); - --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } -.focus\:ring-1:focus { +.focus\:ring-2:focus { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); - --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } From 6691f2c114836e3ab0299da71f5d54a37744a77f Mon Sep 17 00:00:00 2001 From: ndigirigijohn Date: Thu, 13 Feb 2025 12:59:53 +0300 Subject: [PATCH 3/6] progress fixing http and form trigger params --- .../Features/Triggers/FormTrigger.razor | 129 +++--- .../Triggers/HttpRequestTrigger.razor | 368 +++++++++--------- .../Components/Pages/DynamicForm.razor | 138 ++++--- 3 files changed, 345 insertions(+), 290 deletions(-) diff --git a/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/FormTrigger.razor b/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/FormTrigger.razor index 5c0eb7a..5854f89 100644 --- a/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/FormTrigger.razor +++ b/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/FormTrigger.razor @@ -4,56 +4,56 @@
- +

Form Parameters

+
- @foreach (var param in formParameters) + @foreach (var param in Parameters) {
- @if (!string.IsNullOrEmpty(param.ValidationError)) - { -

@param.ValidationError

- } -
+
- + @bind:after="() => ValidateAndUpdateParameters()" /> -
+ @if (!string.IsNullOrEmpty(param.ValidationError)) + { +
@param.ValidationError
+ }
+ @bind:after="() => ValidateAndUpdateParameters()" />
}
+
- +
Form URL
@@ -85,82 +85,96 @@ [Parameter] public Guid WorkflowId { get; set; } [Inject] private NavigationManager NavigationManager { get; set; } = default!; [Inject] private IJSRuntime JSRuntime { get; set; } = default!; - private List formParameters = new(); + + private List Parameters { get; set; } = new(); private string FormUrl => $"{NavigationManager.BaseUri.TrimEnd('/')}/form/{WorkflowId}"; + private int paramCounter = 0; - private class FormParameter + protected override void OnInitialized() { - public string Name { get; set; } = ""; - public string Type { get; set; } = "string"; - public string? Description { get; set; } - public string ValidationError { get; set; } = ""; + InitializeParameters(); } protected override void OnParametersSet() { - if (WorkflowId != Guid.Empty) - { - InitializeParameters(); - } + InitializeParameters(); } private void InitializeParameters() { - formParameters.Clear(); + Parameters.Clear(); foreach (var param in TriggerInput.Parameters) { - formParameters.Add(new FormParameter + Parameters.Add(new ParameterData { Name = param.Key, Type = param.Value.Type.ToString().ToLower(), Description = param.Value.Description }); } + UpdateParamCounter(); } - private async Task AddParameter() + private void UpdateParamCounter() { - var paramName = $"param{formParameters.Count + 1}"; - var param = new FormParameter - { - Name = paramName, - Type = "string", - Description = $"Parameter {paramName}" - }; - formParameters.Add(param); - await UpdateParameters(); - StateHasChanged(); + paramCounter = Parameters + .Select(p => p.Name) + .Where(name => name.StartsWith("param")) + .Select(name => name.Replace("param", "")) + .Where(num => int.TryParse(num, out _)) + .Select(num => int.Parse(num)) + .DefaultIfEmpty(0) + .Max() + 1; } - private async Task RemoveParameter(FormParameter param) + + private void AddParameter() { - formParameters.Remove(param); - await UpdateParameters(); + Parameters.Add(new ParameterData + { + Name = $"param{paramCounter++}", + Type = "string" + }); StateHasChanged(); } - private async Task UpdateParameters() + private async Task RemoveParameter(ParameterData param) { - // Clear and rebuild parameters + Parameters.Remove(param); + await ValidateAndUpdateParameters(); + } + + private async Task ValidateAndUpdateParameters() + { + // Clear existing parameters var existingKeys = TriggerInput.Parameters.Keys.ToList(); foreach (var key in existingKeys) { TriggerInput.Parameters.Remove(key); } - // Validate parameter names + // Validate and update parameters var nameValidationRegex = new Regex("^[a-zA-Z][a-zA-Z0-9]*$"); - foreach (var param in formParameters) + bool hasChanges = false; + + foreach (var param in Parameters) { - param.ValidationError = ""; - if (string.IsNullOrEmpty(param.Name)) + param.ValidationError = null; + + if (string.IsNullOrWhiteSpace(param.Name)) { - param.ValidationError = "Parameter name cannot be empty."; + param.ValidationError = "Parameter name cannot be empty"; continue; } if (!nameValidationRegex.IsMatch(param.Name)) { - param.ValidationError = "Parameter name must start with a letter and contain only letters and numbers."; + param.ValidationError = "Parameter name must start with a letter and contain only letters and numbers"; + continue; + } + + if (Parameters.Count(p => p.Name == param.Name) > 1) + { + param.ValidationError = "Parameter name must be unique"; continue; } @@ -168,16 +182,27 @@ { Type = Enum.Parse(param.Type, true), Description = param.Description ?? $"Enter {param.Name}", - Required = true // Making all form fields required by default + Required = true // All form parameters are required by default }; + hasChanges = true; } - await OnChange.InvokeAsync(); + if (hasChanges) + { + await OnChange.InvokeAsync(); + } } private async Task CopyToClipboard(string text) { await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text); - // Could add toast notification here + } + + private class ParameterData + { + public string Name { get; set; } = ""; + public string Type { get; set; } = "string"; + public string? Description { get; set; } + public string? ValidationError { get; set; } } } \ No newline at end of file diff --git a/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/HttpRequestTrigger.razor b/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/HttpRequestTrigger.razor index a2ff731..9616518 100644 --- a/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/HttpRequestTrigger.razor +++ b/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/HttpRequestTrigger.razor @@ -10,7 +10,7 @@
@if (showToast) { -
+
@toastMessage
} @@ -45,7 +45,7 @@
- +
@@ -58,46 +58,57 @@
- -
- @foreach (var param in httpQueryParameters) - { -
- @if (!string.IsNullOrEmpty(param.ValidationError)) - { -

@param.ValidationError

- } -
- - - - -
- @if (!string.IsNullOrEmpty(param.Description)) - { -

@param.Description

- } + + @foreach (var param in Parameters.Where(p => p.IsRequired)) + { +
+
+ +
- } -
+
@param.Description
+
+ } + + + @foreach (var param in Parameters.Where(p => !p.IsRequired)) + { +
+
+ + + +
+ @if (!string.IsNullOrEmpty(param.ValidationError)) + { +
@param.ValidationError
+ } +
+ } -
- -
+ +
cURL Example
-
-
@CurlCommand
- @if (!isExpanded && CurlCommand.Length > 200) - { -
- - } - @if (isExpanded && CurlCommand.Length > 200) - { - - } -
+
@CurlCommand
+ @code { [Parameter] public TriggerInputHttpRequest TriggerInput { get; set; } = null!; [Parameter] public EventCallback OnChange { get; set; } @@ -143,179 +138,177 @@ private string? toastMessage; private bool showToast; - private bool isExpanded; - private List httpQueryParameters = new(); - private bool areParametersValid = true; - private string deliveryValidationError = ""; + private List Parameters { get; set; } = new(); + private int paramCounter = 0; private string FullUrl => $"{NavigationManager.BaseUri.TrimEnd('/')}/api/workflow/{WorkflowId}"; - private string CurlCommand + protected override void OnInitialized() { - get - { - var command = $"curl -X {TriggerInput.Method} \"{FullUrl}\""; - - var requiredParams = TriggerInput.Parameters.Where(p => p.Value.Required); - - if (TriggerInput.Method == "GET" && requiredParams.Any()) - { - var queryParams = requiredParams - .Select(p => $"{p.Key}={HttpUtility.UrlEncode($"example {p.Key}")}"); - command += $"?{string.Join("&", queryParams)}"; - } - - if (TriggerInput.Method is "POST" or "PUT" && requiredParams.Any()) - { - command += " \\\n -H \"Content-Type: application/json\""; - - var exampleBody = requiredParams - .ToDictionary( - p => p.Key, - p => $"example {p.Key}" - ); - - var jsonBody = System.Text.Json.JsonSerializer.Serialize(exampleBody, - new System.Text.Json.JsonSerializerOptions { - WriteIndented = true, - PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase - }); - command += $" \\\n -d '{jsonBody}'"; - } - - return command; - } - } - - private string JsonSchema - { - get - { - var schema = new - { - type = "object", - required = TriggerInput.Parameters - .Where(p => p.Value.Required) - .Select(p => p.Key) - .ToList(), - properties = TriggerInput.Parameters.ToDictionary( - p => p.Key, - p => new - { - type = p.Value.Type.ToString().ToLower(), - description = p.Value.Description ?? $"Parameter: {p.Key}", - required = p.Value.Required - } - ) - }; - - return System.Text.Json.JsonSerializer.Serialize(schema, - new System.Text.Json.JsonSerializerOptions { - WriteIndented = true, - PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase - }); - } - } - - private class HttpQueryParameters - { - public string Name { get; set; } = ""; - public string Type { get; set; } = "string"; - public string? Description { get; set; } - public bool Required { get; set; } - public string ValidationError { get; set; } = ""; + InitializeParameters(); } protected override void OnParametersSet() { - if (WorkflowId != Guid.Empty) - { - InitializeParameters(); - } + InitializeParameters(); } private void InitializeParameters() { - httpQueryParameters.Clear(); + Parameters.Clear(); foreach (var param in TriggerInput.Parameters) { - httpQueryParameters.Add(new HttpQueryParameters + Parameters.Add(new ParameterData { Name = param.Key, Type = param.Value.Type.ToString().ToLower(), Description = param.Value.Description, - Required = param.Value.Required + IsRequired = param.Value.Required }); } + UpdateParamCounter(); + } + + private void UpdateParamCounter() + { + paramCounter = Parameters + .Select(p => p.Name) + .Where(name => name.StartsWith("param")) + .Select(name => name.Replace("param", "")) + .Where(num => int.TryParse(num, out _)) + .Select(num => int.Parse(num)) + .DefaultIfEmpty(0) + .Max() + 1; } private async Task OnInputChanged() { - await OnChange.InvokeAsync(); + await ValidateAndUpdateParameters(); } - private async Task AddParameter() + private void AddParameter() { - var paramName = $"param{httpQueryParameters.Count + 1}"; - var param = new HttpQueryParameters - { - Name = paramName, + Parameters.Add(new ParameterData + { + Name = $"param{paramCounter++}", Type = "string", - Required = false - }; - httpQueryParameters.Add(param); - await UpdateParameters(); + IsRequired = false + }); StateHasChanged(); } - private async Task RemoveParameter(HttpQueryParameters param) + + private async Task RemoveParameter(ParameterData param) { - httpQueryParameters.Remove(param); - await UpdateParameters(); - StateHasChanged(); + if (!param.IsRequired) + { + Parameters.Remove(param); + await ValidateAndUpdateParameters(); + } } - private async Task UpdateParameters() + private async Task ValidateAndUpdateParameters() { - areParametersValid = true; - deliveryValidationError = ""; - + // Clear existing parameters var existingKeys = TriggerInput.Parameters.Keys.ToList(); foreach (var key in existingKeys) { TriggerInput.Parameters.Remove(key); } - var nameValidationRegex = new Regex("^[a-zA-Z]+$"); + // Validate and update parameters + var nameValidationRegex = new Regex("^[a-zA-Z][a-zA-Z0-9]*$"); + bool hasChanges = false; - foreach (var param in httpQueryParameters) + foreach (var param in Parameters) { - param.ValidationError = ""; + param.ValidationError = null; - if (string.IsNullOrEmpty(param.Name)) + if (string.IsNullOrWhiteSpace(param.Name)) { - param.ValidationError = "Parameter name cannot be empty."; - areParametersValid = false; + param.ValidationError = "Parameter name cannot be empty"; + continue; } - else + + if (!nameValidationRegex.IsMatch(param.Name)) { - if (!nameValidationRegex.IsMatch(param.Name)) - { - param.ValidationError = "Parameter name must contain only letters (a-z, A-Z)."; - areParametersValid = false; - } - else + param.ValidationError = "Parameter name must start with a letter and contain only letters and numbers"; + continue; + } + + if (Parameters.Count(p => p.Name == param.Name) > 1) + { + param.ValidationError = "Parameter name must be unique"; + continue; + } + + TriggerInput.Parameters[param.Name] = new ParameterDefinition + { + Type = Enum.Parse(param.Type, true), + Description = param.Description ?? $"Parameter: {param.Name}", + Required = param.IsRequired + }; + hasChanges = true; + } + + if (hasChanges) + { + await OnChange.InvokeAsync(); + } + } + + private string CurlCommand + { + get + { + var command = $"curl -X {TriggerInput.Method} \"{FullUrl}\""; + + var parameters = Parameters.Where(p => p.IsRequired); + if (!parameters.Any()) return command; + + if (TriggerInput.Method == "GET") + { + var queryParams = parameters.Select(p => $"{p.Name}={HttpUtility.UrlEncode($"example_{p.Name}")}"); + command += $"?{string.Join("&", queryParams)}"; + } + else if (TriggerInput.Method == "POST") + { + command += " \\\n -H \"Content-Type: application/json\""; + var body = parameters.ToDictionary(p => p.Name, p => $"example_{p.Name}"); + var jsonBody = System.Text.Json.JsonSerializer.Serialize(body, new System.Text.Json.JsonSerializerOptions { - TriggerInput.Parameters[param.Name] = new ParameterDefinition - { - Type = Enum.Parse(param.Type, true), - Description = $"Custom parameter: {param.Name}", - Required = param.Required - }; - } + WriteIndented = true + }); + command += $" \\\n -d '{jsonBody}'"; } + + return command; } + } - await OnChange.InvokeAsync(); + private string JsonSchema + { + get + { + var schema = new + { + type = "object", + required = Parameters.Where(p => p.IsRequired).Select(p => p.Name).ToList(), + properties = Parameters.ToDictionary( + p => p.Name, + p => new + { + type = p.Type, + description = p.Description ?? $"Parameter: {p.Name}", + required = p.IsRequired + } + ) + }; + + return System.Text.Json.JsonSerializer.Serialize(schema, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true + }); + } } private async Task CopyToClipboard(string text) @@ -328,4 +321,13 @@ showToast = false; StateHasChanged(); } + + private class ParameterData + { + public string Name { get; set; } = ""; + public string Type { get; set; } = "string"; + public string? Description { get; set; } + public bool IsRequired { get; set; } + public string? ValidationError { get; set; } + } } \ No newline at end of file diff --git a/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor b/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor index 3a03e9b..604fb99 100644 --- a/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor +++ b/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor @@ -2,7 +2,6 @@ @using Blocktrust.CredentialWorkflow.Core.Commands.Workflow.GetWorkflowById @using Blocktrust.CredentialWorkflow.Core.Domain.ProcessFlow.Triggers @using MediatR -@using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Authorization @using Blocktrust.CredentialWorkflow.Core.Services @inject IMediator Mediator @@ -10,9 +9,7 @@ @inject ILogger Logger @inject IFormService FormService @attribute [AllowAnonymous] - Workflow Form -
@if (isLoading) @@ -44,16 +41,18 @@ } else { - + - @foreach (var field in formModel.Fields) {
- @switch (field.Type.ToLower()) { case "string": @@ -62,30 +61,25 @@ type="@(field.Type.ToLower() == "email" ? "email" : "text")" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 sm:text-sm" /> break; - case "number": break; - case "boolean": break; - case "date": break; } - @if (!string.IsNullOrEmpty(field.ValidationMessage)) {
@field.ValidationMessage
}
} -
- @code { [Parameter] public Guid WorkflowId { get; set; } - private bool isLoading = true; private string? error; private string? successMessage; @@ -160,7 +152,41 @@ { try { - if (!ValidateForm()) + // Perform manual validation before submission + bool isValid = true; + foreach (var field in formModel.Fields) + { + field.ValidationMessage = null; + if (field.IsRequired) + { + switch (field.Type.ToLower()) + { + case "string": + case "email": + if (string.IsNullOrWhiteSpace(field.StringValue)) + { + field.ValidationMessage = $"{field.Name} is required."; + isValid = false; + } + else if (field.Type.ToLower() == "email" && !IsValidEmail(field.StringValue)) + { + field.ValidationMessage = $"{field.Name} must be a valid email address."; + isValid = false; + } + break; + case "number": + // NumberValue is initialized to 0 by default, so we need to check if it was actually set + if (!field.IsNumberSet) + { + field.ValidationMessage = $"{field.Name} is required."; + isValid = false; + } + break; + } + } + } + + if (!isValid) { return; } @@ -177,7 +203,7 @@ if (result.IsSuccess) { successMessage = "Form submitted successfully!"; - formModel = new DynamicFormModel(); // Reset form + formModel = new DynamicFormModel(); } else { @@ -196,17 +222,17 @@ } } - private bool ValidateForm() + private bool IsValidEmail(string email) { - bool isValid = true; - foreach (var field in formModel.Fields) + try { - if (!field.Validate()) - { - isValid = false; - } + var addr = new System.Net.Mail.MailAddress(email); + return addr.Address == email; + } + catch + { + return false; } - return isValid; } public class DynamicFormModel @@ -222,48 +248,50 @@ public bool IsRequired { get; set; } public string? ValidationMessage { get; set; } - public string StringValue { get; set; } = ""; - public decimal NumberValue { get; set; } - public bool BoolValue { get; set; } - public DateTime DateValue { get; set; } = DateTime.Today; - - public bool Validate() + private string _stringValue = ""; + public string StringValue { - ValidationMessage = null; - - if (IsRequired) + get => _stringValue; + set { - switch (Type.ToLower()) - { - case "string": - case "email": - if (string.IsNullOrWhiteSpace(StringValue)) - { - ValidationMessage = $"{Name} is required."; - return false; - } - if (Type.ToLower() == "email" && !IsValidEmail(StringValue)) - { - ValidationMessage = $"{Name} must be a valid email address."; - return false; - } - break; - } + _stringValue = value; + ValidationMessage = null; } + } - return true; + private decimal _numberValue; + private bool _isNumberSet; + public decimal NumberValue + { + get => _numberValue; + set + { + _numberValue = value; + _isNumberSet = true; + ValidationMessage = null; + } } + public bool IsNumberSet => _isNumberSet; - private bool IsValidEmail(string email) + private bool _boolValue; + public bool BoolValue { - try + get => _boolValue; + set { - var addr = new System.Net.Mail.MailAddress(email); - return addr.Address == email; + _boolValue = value; + ValidationMessage = null; } - catch + } + + private DateTime _dateValue = DateTime.Today; + public DateTime DateValue + { + get => _dateValue; + set { - return false; + _dateValue = value; + ValidationMessage = null; } } From c7ad965fe4cd14398face6cf280dcaad77a7a4f8 Mon Sep 17 00:00:00 2001 From: ndigirigijohn Date: Fri, 14 Feb 2025 12:43:44 +0300 Subject: [PATCH 4/6] improved parameter configuration on http trigger component --- .../GetWorkflowOutcomeByIdHandler.cs | 1 - .../GetWorkflowOutcomeIdsByStateHandler.cs | 51 ++- .../UpdateWorkflowOutcomeStateHandler.cs | 49 ++- .../Triggers/HttpRequestTrigger.razor | 318 ++++++++++-------- 4 files changed, 231 insertions(+), 188 deletions(-) diff --git a/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/GetWorkflowOutcomeById/GetWorkflowOutcomeByIdHandler.cs b/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/GetWorkflowOutcomeById/GetWorkflowOutcomeByIdHandler.cs index 81ad471..17e8b3b 100644 --- a/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/GetWorkflowOutcomeById/GetWorkflowOutcomeByIdHandler.cs +++ b/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/GetWorkflowOutcomeById/GetWorkflowOutcomeByIdHandler.cs @@ -1,6 +1,5 @@ namespace Blocktrust.CredentialWorkflow.Core.Commands.WorkflowOutcome.GetWorkflowOutcomeById; -using Blocktrust.CredentialWorkflow.Core.Domain.ProcessFlow.Actions; using Domain.Workflow; using FluentResults; using MediatR; diff --git a/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/GetWorkflowOutcomeIdsByState/GetWorkflowOutcomeIdsByStateHandler.cs b/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/GetWorkflowOutcomeIdsByState/GetWorkflowOutcomeIdsByStateHandler.cs index 6af61a1..771fbd9 100644 --- a/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/GetWorkflowOutcomeIdsByState/GetWorkflowOutcomeIdsByStateHandler.cs +++ b/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/GetWorkflowOutcomeIdsByState/GetWorkflowOutcomeIdsByStateHandler.cs @@ -1,33 +1,32 @@ -namespace Blocktrust.CredentialWorkflow.Core.Commands.WorkflowOutcome.GetWorkflowOutcomeIdsByState +namespace Blocktrust.CredentialWorkflow.Core.Commands.WorkflowOutcome.GetWorkflowOutcomeIdsByState; + +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; + +public class GetWorkflowOutcomeIdsByStateHandler + : IRequestHandler>> { - using FluentResults; - using MediatR; - using Microsoft.EntityFrameworkCore; + private readonly DataContext _context; - public class GetWorkflowOutcomeIdsByStateHandler - : IRequestHandler>> + public GetWorkflowOutcomeIdsByStateHandler(DataContext context) { - private readonly DataContext _context; - - public GetWorkflowOutcomeIdsByStateHandler(DataContext context) - { - _context = context; - } + _context = context; + } - public async Task>> Handle( - GetWorkflowOutcomeIdsByStateRequest request, - CancellationToken cancellationToken) - { - var outcomes = await _context.WorkflowOutcomeEntities - .Where(o => request.WorkflowOutcomeStates.Contains(o.WorkflowOutcomeState)) - .Select(o => new GetWorkflowOutcomeIdsByStateResponse - { - OutcomeId = o.WorkflowOutcomeEntityId, - WorkflowOutcomeState = o.WorkflowOutcomeState - }) - .ToListAsync(cancellationToken); + public async Task>> Handle( + GetWorkflowOutcomeIdsByStateRequest request, + CancellationToken cancellationToken) + { + var outcomes = await _context.WorkflowOutcomeEntities + .Where(o => request.WorkflowOutcomeStates.Contains(o.WorkflowOutcomeState)) + .Select(o => new GetWorkflowOutcomeIdsByStateResponse + { + OutcomeId = o.WorkflowOutcomeEntityId, + WorkflowOutcomeState = o.WorkflowOutcomeState + }) + .ToListAsync(cancellationToken); - return Result.Ok(outcomes); - } + return Result.Ok(outcomes); } } \ No newline at end of file diff --git a/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/UpdateWorkflowOutcomeState/UpdateWorkflowOutcomeStateHandler.cs b/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/UpdateWorkflowOutcomeState/UpdateWorkflowOutcomeStateHandler.cs index f03b1b4..5452d67 100644 --- a/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/UpdateWorkflowOutcomeState/UpdateWorkflowOutcomeStateHandler.cs +++ b/Blocktrust.CredentialWorkflow.Core/Commands/WorkflowOutcome/UpdateWorkflowOutcomeState/UpdateWorkflowOutcomeStateHandler.cs @@ -1,36 +1,35 @@ -namespace Blocktrust.CredentialWorkflow.Core.Commands.WorkflowOutcome.UpdateWorkflowOutcomeState +namespace Blocktrust.CredentialWorkflow.Core.Commands.WorkflowOutcome.UpdateWorkflowOutcomeState; + +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; + +public class UpdateWorkflowOutcomeStateHandler : IRequestHandler { - using FluentResults; - using MediatR; - using Microsoft.EntityFrameworkCore; + private readonly DataContext _context; - public class UpdateWorkflowOutcomeStateHandler : IRequestHandler + public UpdateWorkflowOutcomeStateHandler(DataContext context) { - private readonly DataContext _context; + _context = context; + } - public UpdateWorkflowOutcomeStateHandler(DataContext context) - { - _context = context; - } + public async Task Handle(UpdateWorkflowOutcomeStateRequest request, CancellationToken cancellationToken) + { + // Retrieve the outcome from the database + var outcomeEntity = await _context.WorkflowOutcomeEntities + .FirstOrDefaultAsync(o => o.WorkflowOutcomeEntityId == request.WorkflowOutcomeId, cancellationToken); - public async Task Handle(UpdateWorkflowOutcomeStateRequest request, CancellationToken cancellationToken) + if (outcomeEntity is null) { - // Retrieve the outcome from the database - var outcomeEntity = await _context.WorkflowOutcomeEntities - .FirstOrDefaultAsync(o => o.WorkflowOutcomeEntityId == request.WorkflowOutcomeId, cancellationToken); - - if (outcomeEntity is null) - { - return Result.Fail("The specified outcome does not exist in the database."); - } + return Result.Fail("The specified outcome does not exist in the database."); + } - // Update the state - outcomeEntity.WorkflowOutcomeState = request.NewState; + // Update the state + outcomeEntity.WorkflowOutcomeState = request.NewState; - // Save changes to the database - await _context.SaveChangesAsync(cancellationToken); + // Save changes to the database + await _context.SaveChangesAsync(cancellationToken); - return Result.Ok(); - } + return Result.Ok(); } } \ No newline at end of file diff --git a/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/HttpRequestTrigger.razor b/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/HttpRequestTrigger.razor index 9616518..f2211d2 100644 --- a/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/HttpRequestTrigger.razor +++ b/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/HttpRequestTrigger.razor @@ -21,95 +21,115 @@
Endpoint URL
-
-
- @NavigationManager.BaseUri.TrimEnd('/')/api/workflow/ - @WorkflowId -
+
+ @FullUrl
- +
-
-
-

Parameters

- -
+
+

Parameters

+
- - @foreach (var param in Parameters.Where(p => p.IsRequired)) - { -
-
- - -
-
@param.Description
-
- } - - - @foreach (var param in Parameters.Where(p => !p.IsRequired)) - { -
-
- - - + +
+ @foreach (var param in Parameters) + { +
+
+ @if (param.IsEditing) + { + + +
+ + +
+ } + else + { +
@param.Name
+
@param.Type
+
+ + @if (!param.IsRequired) + { + + } +
+ } +
+ + @if (param.IsEditing) + { + + } + else if (!string.IsNullOrEmpty(param.Description)) + { +
@param.Description
+ } + + @if (!string.IsNullOrEmpty(param.ValidationError)) + { +
@param.ValidationError
+ }
- @if (!string.IsNullOrEmpty(param.ValidationError)) - { -
@param.ValidationError
- } -
- } + } +
-
-
+
cURL Example
@@ -139,8 +159,6 @@ private string? toastMessage; private bool showToast; private List Parameters { get; set; } = new(); - private int paramCounter = 0; - private string FullUrl => $"{NavigationManager.BaseUri.TrimEnd('/')}/api/workflow/{WorkflowId}"; protected override void OnInitialized() @@ -166,94 +184,115 @@ IsRequired = param.Value.Required }); } - UpdateParamCounter(); } - private void UpdateParamCounter() + private async Task OnInputChanged() { - paramCounter = Parameters - .Select(p => p.Name) - .Where(name => name.StartsWith("param")) - .Select(name => name.Replace("param", "")) - .Where(num => int.TryParse(num, out _)) - .Select(num => int.Parse(num)) - .DefaultIfEmpty(0) - .Max() + 1; + await UpdateTriggerParameters(); } - private async Task OnInputChanged() + private void AddParameter() { - await ValidateAndUpdateParameters(); + var newParam = new ParameterData + { + IsEditing = true, + TempName = "", + TempType = "string", + TempDescription = "" + }; + Parameters.Add(newParam); } - private void AddParameter() + private void StartEdit(ParameterData param) { - Parameters.Add(new ParameterData - { - Name = $"param{paramCounter++}", - Type = "string", - IsRequired = false - }); - StateHasChanged(); + param.IsEditing = true; + param.TempName = param.Name; + param.TempType = param.Type; + param.TempDescription = param.Description; + param.ValidationError = null; } - private async Task RemoveParameter(ParameterData param) + private void CancelEdit(ParameterData param) { - if (!param.IsRequired) + if (string.IsNullOrEmpty(param.Name)) { Parameters.Remove(param); - await ValidateAndUpdateParameters(); + } + else + { + param.IsEditing = false; + param.ValidationError = null; } } - private async Task ValidateAndUpdateParameters() + private async Task SaveParameter(ParameterData param) { - // Clear existing parameters - var existingKeys = TriggerInput.Parameters.Keys.ToList(); - foreach (var key in existingKeys) + if (ValidateParameter(param)) { - TriggerInput.Parameters.Remove(key); + param.Name = param.TempName!; + param.Type = param.TempType!; + param.Description = param.TempDescription; + param.IsEditing = false; + param.ValidationError = null; + await UpdateTriggerParameters(); } + } - // Validate and update parameters + private bool ValidateParameter(ParameterData param) + { var nameValidationRegex = new Regex("^[a-zA-Z][a-zA-Z0-9]*$"); - bool hasChanges = false; - foreach (var param in Parameters) + if (string.IsNullOrWhiteSpace(param.TempName)) { - param.ValidationError = null; + param.ValidationError = "Parameter name cannot be empty"; + return false; + } - if (string.IsNullOrWhiteSpace(param.Name)) - { - param.ValidationError = "Parameter name cannot be empty"; - continue; - } + if (!nameValidationRegex.IsMatch(param.TempName)) + { + param.ValidationError = "Parameter name must start with a letter and contain only letters and numbers"; + return false; + } - if (!nameValidationRegex.IsMatch(param.Name)) - { - param.ValidationError = "Parameter name must start with a letter and contain only letters and numbers"; - continue; - } + if (Parameters.Any(p => p != param && p.Name == param.TempName)) + { + param.ValidationError = "Parameter name must be unique"; + return false; + } - if (Parameters.Count(p => p.Name == param.Name) > 1) - { - param.ValidationError = "Parameter name must be unique"; - continue; - } + return true; + } + private async Task RemoveParameter(ParameterData param) + { + if (!param.IsRequired) + { + Parameters.Remove(param); + await UpdateTriggerParameters(); + } + } + + private async Task UpdateTriggerParameters() + { + // Clear existing parameters + var existingKeys = TriggerInput.Parameters.Keys.ToList(); + foreach (var key in existingKeys) + { + TriggerInput.Parameters.Remove(key); + } + + // Update with current parameters + foreach (var param in Parameters.Where(p => !p.IsEditing && !string.IsNullOrEmpty(p.Name))) + { TriggerInput.Parameters[param.Name] = new ParameterDefinition { Type = Enum.Parse(param.Type, true), Description = param.Description ?? $"Parameter: {param.Name}", Required = param.IsRequired }; - hasChanges = true; } - if (hasChanges) - { - await OnChange.InvokeAsync(); - } + await OnChange.InvokeAsync(); } private string CurlCommand @@ -262,18 +301,18 @@ { var command = $"curl -X {TriggerInput.Method} \"{FullUrl}\""; - var parameters = Parameters.Where(p => p.IsRequired); + var parameters = Parameters.Where(p => !p.IsEditing && !string.IsNullOrEmpty(p.Name)); if (!parameters.Any()) return command; if (TriggerInput.Method == "GET") { - var queryParams = parameters.Select(p => $"{p.Name}={HttpUtility.UrlEncode($"example_{p.Name}")}"); + var queryParams = parameters.Select(p => $"{p.Name}={HttpUtility.UrlEncode($"value_{p.Name}")}"); command += $"?{string.Join("&", queryParams)}"; } - else if (TriggerInput.Method == "POST") + else { command += " \\\n -H \"Content-Type: application/json\""; - var body = parameters.ToDictionary(p => p.Name, p => $"example_{p.Name}"); + var body = parameters.ToDictionary(p => p.Name, p => $"value_{p.Name}"); var jsonBody = System.Text.Json.JsonSerializer.Serialize(body, new System.Text.Json.JsonSerializerOptions { WriteIndented = true @@ -292,16 +331,17 @@ var schema = new { type = "object", - required = Parameters.Where(p => p.IsRequired).Select(p => p.Name).ToList(), - properties = Parameters.ToDictionary( - p => p.Name, - p => new - { - type = p.Type, - description = p.Description ?? $"Parameter: {p.Name}", - required = p.IsRequired - } - ) + required = Parameters.Where(p => p.IsRequired && !p.IsEditing).Select(p => p.Name).ToList(), + properties = Parameters + .Where(p => !p.IsEditing && !string.IsNullOrEmpty(p.Name)) + .ToDictionary( + p => p.Name, + p => new + { + type = p.Type, + description = p.Description ?? $"Parameter: {p.Name}" + } + ) }; return System.Text.Json.JsonSerializer.Serialize(schema, new System.Text.Json.JsonSerializerOptions @@ -329,5 +369,11 @@ public string? Description { get; set; } public bool IsRequired { get; set; } public string? ValidationError { get; set; } + + // Editing state + public bool IsEditing { get; set; } + public string? TempName { get; set; } + public string? TempType { get; set; } + public string? TempDescription { get; set; } } } \ No newline at end of file From 00fab548c659a03a63e79ed92df78c73d78a1892 Mon Sep 17 00:00:00 2001 From: ndigirigijohn Date: Fri, 14 Feb 2025 12:55:10 +0300 Subject: [PATCH 5/6] improved form configuration UI --- .../Features/Triggers/FormTrigger.razor | 373 ++++++++++++------ .../wwwroot/app.css | 60 ++- 2 files changed, 284 insertions(+), 149 deletions(-) diff --git a/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/FormTrigger.razor b/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/FormTrigger.razor index 5854f89..cf80aa1 100644 --- a/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/FormTrigger.razor +++ b/Blocktrust.CredentialWorkflow.Web/Components/Features/Triggers/FormTrigger.razor @@ -2,80 +2,158 @@ @using System.Text.RegularExpressions @namespace Blocktrust.CredentialWorkflow.Web.Components.Features.Triggers -
+
+ @if (showToast) + { +
+ @toastMessage +
+ } +
- + +
+
+
Form URL
+ +
+
+ @FormUrl +
+
+ +
-
-
-

Form Parameters

-
+
+

Form Fields

+
All fields are required by default
- -
+ +
@foreach (var param in Parameters) { -
-
- - - +
+
+ @if (param.IsEditing) + { + + +
+ + +
+ } + else + { +
@param.Name
+
+ @GetDisplayType(param.Type) +
+
+ + +
+ }
+ + @if (param.IsEditing) + { +
+ + +
+ + +
+
+ } + else + { + @if (!string.IsNullOrEmpty(param.Description)) + { +
@param.Description
+ } + @if (param.AllowedValues?.Any() == true) + { +
+ Allowed values: @string.Join(", ", param.AllowedValues) +
+ } + @if (!string.IsNullOrEmpty(param.DefaultValue)) + { +
+ Default: @param.DefaultValue +
+ } + } + @if (!string.IsNullOrEmpty(param.ValidationError)) { -
@param.ValidationError
+
@param.ValidationError
} -
- -
}
- -
- -
-
-
Form URL
- -
-
- @FormUrl + + @if (Parameters.Any(p => !p.IsEditing && !string.IsNullOrEmpty(p.Name))) + { + -
+ }
@@ -83,12 +161,14 @@ [Parameter] public TriggerInputForm TriggerInput { get; set; } = null!; [Parameter] public EventCallback OnChange { get; set; } [Parameter] public Guid WorkflowId { get; set; } + [Inject] private NavigationManager NavigationManager { get; set; } = default!; [Inject] private IJSRuntime JSRuntime { get; set; } = default!; - - private List Parameters { get; set; } = new(); + + private string? toastMessage; + private bool showToast; + private List Parameters { get; set; } = new(); private string FormUrl => $"{NavigationManager.BaseUri.TrimEnd('/')}/form/{WorkflowId}"; - private int paramCounter = 0; protected override void OnInitialized() { @@ -105,104 +185,173 @@ Parameters.Clear(); foreach (var param in TriggerInput.Parameters) { - Parameters.Add(new ParameterData + Parameters.Add(new FieldData { Name = param.Key, Type = param.Value.Type.ToString().ToLower(), - Description = param.Value.Description + Description = param.Value.Description, + AllowedValues = param.Value.AllowedValues, + DefaultValue = param.Value.DefaultValue }); } - UpdateParamCounter(); } - private void UpdateParamCounter() + private string GetDisplayType(string type) => type switch { - paramCounter = Parameters - .Select(p => p.Name) - .Where(name => name.StartsWith("param")) - .Select(name => name.Replace("param", "")) - .Where(num => int.TryParse(num, out _)) - .Select(num => int.Parse(num)) - .DefaultIfEmpty(0) - .Max() + 1; - } + "string" => "Text", + "number" => "Number", + "boolean" => "Yes/No", + "date" => "Date", + _ => type + }; private void AddParameter() { - Parameters.Add(new ParameterData - { - Name = $"param{paramCounter++}", - Type = "string" - }); - StateHasChanged(); + var newParam = new FieldData + { + IsEditing = true, + TempName = "", + TempType = "string", + TempDescription = "" + }; + Parameters.Add(newParam); } - private async Task RemoveParameter(ParameterData param) + private void StartEdit(FieldData param) { - Parameters.Remove(param); - await ValidateAndUpdateParameters(); + param.IsEditing = true; + param.TempName = param.Name; + param.TempType = param.Type; + param.TempDescription = param.Description; + param.TempDefaultValue = param.DefaultValue; + param.TempAllowedValuesText = param.AllowedValues != null ? string.Join("\n", param.AllowedValues) : null; + param.ValidationError = null; } - private async Task ValidateAndUpdateParameters() + private void CancelEdit(FieldData param) { - // Clear existing parameters - var existingKeys = TriggerInput.Parameters.Keys.ToList(); - foreach (var key in existingKeys) + if (string.IsNullOrEmpty(param.Name)) { - TriggerInput.Parameters.Remove(key); + Parameters.Remove(param); + } + else + { + param.IsEditing = false; + param.ValidationError = null; + } + } + + private async Task SaveParameter(FieldData param) + { + if (ValidateParameter(param)) + { + param.Name = param.TempName!; + param.Type = param.TempType!; + param.Description = param.TempDescription ?? ""; + param.DefaultValue = param.TempDefaultValue; + param.AllowedValues = param.TempAllowedValues; + param.IsEditing = false; + param.ValidationError = null; + await UpdateTriggerParameters(); } + } - // Validate and update parameters + private bool ValidateParameter(FieldData param) + { var nameValidationRegex = new Regex("^[a-zA-Z][a-zA-Z0-9]*$"); - bool hasChanges = false; - foreach (var param in Parameters) + if (string.IsNullOrWhiteSpace(param.TempName)) { - param.ValidationError = null; + param.ValidationError = "Field name cannot be empty"; + return false; + } - if (string.IsNullOrWhiteSpace(param.Name)) - { - param.ValidationError = "Parameter name cannot be empty"; - continue; - } + if (!nameValidationRegex.IsMatch(param.TempName)) + { + param.ValidationError = "Field name must start with a letter and contain only letters and numbers"; + return false; + } - if (!nameValidationRegex.IsMatch(param.Name)) - { - param.ValidationError = "Parameter name must start with a letter and contain only letters and numbers"; - continue; - } + if (Parameters.Any(p => p != param && p.Name == param.TempName)) + { + param.ValidationError = "Field name must be unique"; + return false; + } - if (Parameters.Count(p => p.Name == param.Name) > 1) - { - param.ValidationError = "Parameter name must be unique"; - continue; - } + if (string.IsNullOrWhiteSpace(param.TempDescription)) + { + param.ValidationError = "Description cannot be empty"; + return false; + } + return true; + } + + private async Task RemoveParameter(FieldData param) + { + Parameters.Remove(param); + await UpdateTriggerParameters(); + } + + private async Task UpdateTriggerParameters() + { + // Clear existing parameters + var existingKeys = TriggerInput.Parameters.Keys.ToList(); + foreach (var key in existingKeys) + { + TriggerInput.Parameters.Remove(key); + } + + // Update with current parameters + foreach (var param in Parameters.Where(p => !p.IsEditing && !string.IsNullOrEmpty(p.Name))) + { TriggerInput.Parameters[param.Name] = new ParameterDefinition { Type = Enum.Parse(param.Type, true), - Description = param.Description ?? $"Enter {param.Name}", - Required = true // All form parameters are required by default + Description = param.Description, + AllowedValues = param.AllowedValues, + DefaultValue = param.DefaultValue, + Required = true // All form fields are required by default }; - hasChanges = true; } - if (hasChanges) - { - await OnChange.InvokeAsync(); - } + await OnChange.InvokeAsync(); } private async Task CopyToClipboard(string text) { await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text); + toastMessage = "Copied to clipboard!"; + showToast = true; + StateHasChanged(); + await Task.Delay(2000); + showToast = false; + StateHasChanged(); } - private class ParameterData + private class FieldData { public string Name { get; set; } = ""; public string Type { get; set; } = "string"; - public string? Description { get; set; } + public string Description { get; set; } = ""; + public string[]? AllowedValues { get; set; } + public string? DefaultValue { get; set; } public string? ValidationError { get; set; } + + // Editing state + public bool IsEditing { get; set; } + public string? TempName { get; set; } + public string? TempType { get; set; } + public string? TempDescription { get; set; } + public string? TempDefaultValue { get; set; } + private string? _tempAllowedValuesText; + public string? TempAllowedValuesText + { + get => _tempAllowedValuesText ?? (AllowedValues != null ? string.Join("\n", AllowedValues) : null); + set => _tempAllowedValuesText = value; + } + public string[]? TempAllowedValues => !string.IsNullOrWhiteSpace(TempAllowedValuesText) + ? TempAllowedValuesText.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : null; } } \ No newline at end of file diff --git a/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css b/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css index 3c25284..9ccee81 100644 --- a/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css +++ b/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css @@ -620,10 +620,6 @@ video { bottom: 0px; } -.bottom-1 { - bottom: 0.25rem; -} - .left-0 { left: 0px; } @@ -837,14 +833,6 @@ video { height: 100vh; } -.max-h-24 { - max-height: 6rem; -} - -.max-h-none { - max-height: none; -} - .min-h-\[100px\] { min-height: 100px; } @@ -881,8 +869,8 @@ video { width: 66.666667%; } -.w-24 { - width: 6rem; +.w-28 { + width: 7rem; } .w-3\/4 { @@ -1125,10 +1113,6 @@ video { overflow: hidden; } -.overflow-scroll { - overflow: scroll; -} - .overflow-x-auto { overflow-x: auto; } @@ -1420,20 +1404,6 @@ video { --tw-bg-opacity: 0.5; } -.bg-gradient-to-t { - background-image: linear-gradient(to top, var(--tw-gradient-stops)); -} - -.from-white { - --tw-gradient-from: #fff var(--tw-gradient-from-position); - --tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); -} - -.to-transparent { - --tw-gradient-to: transparent var(--tw-gradient-to-position); -} - .object-cover { -o-object-fit: cover; object-fit: cover; @@ -1829,6 +1799,17 @@ video { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.ring-2 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.ring-slate-500 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(100 116 139 / var(--tw-ring-opacity)); +} + .transition { transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; @@ -1966,11 +1947,6 @@ a { color: rgb(29 78 216 / var(--tw-text-opacity)); } -.hover\:text-blue-800:hover { - --tw-text-opacity: 1; - color: rgb(30 64 175 / var(--tw-text-opacity)); -} - .hover\:text-blue-900:hover { --tw-text-opacity: 1; color: rgb(30 58 138 / var(--tw-text-opacity)); @@ -1996,6 +1972,11 @@ a { color: rgb(17 24 39 / var(--tw-text-opacity)); } +.hover\:text-green-800:hover { + --tw-text-opacity: 1; + color: rgb(22 101 52 / var(--tw-text-opacity)); +} + .hover\:text-green-900:hover { --tw-text-opacity: 1; color: rgb(20 83 45 / var(--tw-text-opacity)); @@ -2021,6 +2002,11 @@ a { color: rgb(51 65 85 / var(--tw-text-opacity)); } +.hover\:text-slate-900:hover { + --tw-text-opacity: 1; + color: rgb(15 23 42 / var(--tw-text-opacity)); +} + .hover\:opacity-80:hover { opacity: 0.8; } From 2ce3568cf8661a09f619a736dc24abb14ba11993 Mon Sep 17 00:00:00 2001 From: ndigirigijohn Date: Fri, 14 Feb 2025 13:25:11 +0300 Subject: [PATCH 6/6] improved UI and parameters handling on dynamic form --- .../Components/Pages/DynamicForm.razor | 431 +++++++++++++----- .../wwwroot/app.css | 152 +++++- 2 files changed, 453 insertions(+), 130 deletions(-) diff --git a/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor b/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor index 604fb99..19c6afc 100644 --- a/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor +++ b/Blocktrust.CredentialWorkflow.Web/Components/Pages/DynamicForm.razor @@ -4,98 +4,209 @@ @using MediatR @using Microsoft.AspNetCore.Authorization @using Blocktrust.CredentialWorkflow.Core.Services + @inject IMediator Mediator @inject NavigationManager NavigationManager @inject ILogger Logger @inject IFormService FormService + @attribute [AllowAnonymous] -Workflow Form -
-
+ +
+
@if (isLoading) { -
-
+
+
} - else if (!string.IsNullOrEmpty(error)) + else { -
-
-
-

Error

-

@error

+
+ @if (!string.IsNullOrEmpty(error)) + { +
+
+
+
+ error +
+
+

Error

+
@error
+
+
+
-
-
- } - else if (!string.IsNullOrEmpty(successMessage)) - { -
-
-
-

@successMessage

+ } + else if (!string.IsNullOrEmpty(successMessage)) + { +
+
+
+ check_circle +
+

@successMessage

+
+ +
+
-
-
- } - else - { - - - @foreach (var field in formModel.Fields) + } + else { -
- - @switch (field.Type.ToLower()) - { - case "string": - case "email": - - break; - case "number": - - break; - case "boolean": - - break; - case "date": - - break; - } - @if (!string.IsNullOrEmpty(field.ValidationMessage)) - { -
@field.ValidationMessage
- } +
+
+

@(formTitle ?? "New Form")

+
+ + +
+ @foreach (var field in formModel.Fields) + { +
+ + + @switch (field.Type.ToLower()) + { + case "string": + @if (field.AllowedValues?.Any() == true) + { + + @if (!field.IsRequired) + { + + } + @foreach (var value in field.AllowedValues) + { + + } + + } + else + { +
+ + @if (!string.IsNullOrEmpty(field.ValidationMessage)) + { +
+ error +
+ } +
+ } + break; + + case "number": +
+ + @if (!string.IsNullOrEmpty(field.ValidationMessage)) + { +
+ error +
+ } +
+ break; + + case "boolean": +
+ + @if (!string.IsNullOrEmpty(field.DefaultValue)) + { + @field.DefaultValue + } +
+ break; + + case "date": +
+ + @if (!string.IsNullOrEmpty(field.ValidationMessage)) + { +
+ error +
+ } +
+ break; + + case "email": +
+ + @if (!string.IsNullOrEmpty(field.ValidationMessage)) + { +
+ error +
+ } +
+ break; + } + + @if (!string.IsNullOrEmpty(field.ValidationMessage)) + { +

+ error + @field.ValidationMessage +

+ } +
+ } + +
+ +
+
+
} -
- -
- +
}
+ @code { - [Parameter] - public Guid WorkflowId { get; set; } + [Parameter] public Guid WorkflowId { get; set; } + private bool isLoading = true; + private bool isSubmitting = false; private string? error; private string? successMessage; + private string? formTitle; private DynamicFormModel formModel = new(); protected override async Task OnInitializedAsync() @@ -105,26 +216,27 @@ var result = await Mediator.Send(new GetWorkflowByIdRequest(WorkflowId)); if (result.IsFailed) { - error = "Failed to load the form. The workflow may not exist."; + error = "Unable to load the form. Please try again later."; return; } var workflow = result.Value; if (workflow.ProcessFlow?.Triggers == null || !workflow.ProcessFlow.Triggers.Any()) { - error = "This workflow does not have any triggers configured."; + error = "This form is not properly configured."; return; } var trigger = workflow.ProcessFlow.Triggers.First().Value; if (trigger.Type != ETriggerType.Form) { - error = "This workflow is not configured with a form trigger."; + error = "Invalid form configuration."; return; } if (trigger.Input is TriggerInputForm formTrigger) { + formTitle = workflow.Name; foreach (var param in formTrigger.Parameters) { formModel.Fields.Add(new FormField @@ -132,14 +244,16 @@ Name = param.Key, Type = param.Value.Type.ToString().ToLower(), Description = param.Value.Description, - IsRequired = param.Value.Required + IsRequired = param.Value.Required, + DefaultValue = param.Value.DefaultValue, + AllowedValues = param.Value.AllowedValues }); } } } catch (Exception ex) { - error = "An unexpected error occurred while loading the form."; + error = "An error occurred while loading the form."; Logger.LogError(ex, "Error loading form for workflow {WorkflowId}", WorkflowId); } finally @@ -150,39 +264,51 @@ private async Task HandleSubmit() { + if (isSubmitting) return; + try { - // Perform manual validation before submission - bool isValid = true; + // Clear any existing validation messages foreach (var field in formModel.Fields) { field.ValidationMessage = null; - if (field.IsRequired) + } + + // Validate the form data + bool isValid = true; + foreach (var field in formModel.Fields.Where(f => f.IsRequired)) + { + switch (field.Type.ToLower()) { - switch (field.Type.ToLower()) - { - case "string": - case "email": - if (string.IsNullOrWhiteSpace(field.StringValue)) - { - field.ValidationMessage = $"{field.Name} is required."; - isValid = false; - } - else if (field.Type.ToLower() == "email" && !IsValidEmail(field.StringValue)) - { - field.ValidationMessage = $"{field.Name} must be a valid email address."; - isValid = false; - } - break; - case "number": - // NumberValue is initialized to 0 by default, so we need to check if it was actually set - if (!field.IsNumberSet) - { - field.ValidationMessage = $"{field.Name} is required."; - isValid = false; - } - break; - } + case "string": + case "email": + if (string.IsNullOrWhiteSpace(field.StringValue)) + { + field.ValidationMessage = "This field is required"; + isValid = false; + } + else if (field.Type.ToLower() == "email" && !IsValidEmail(field.StringValue)) + { + field.ValidationMessage = "Please enter a valid email address"; + isValid = false; + } + break; + + case "number": + if (!field.IsNumberSet) + { + field.ValidationMessage = "This field is required"; + isValid = false; + } + break; + + case "date": + if (field.DateValue == default) + { + field.ValidationMessage = "This field is required"; + isValid = false; + } + break; } } @@ -191,23 +317,27 @@ return; } - isLoading = true; + isSubmitting = true; StateHasChanged(); + // Prepare form data var formData = formModel.Fields.ToDictionary( field => field.Name, field => field.GetValue() ); + // Submit the form data var result = await FormService.ProcessFormSubmission(WorkflowId, formData); + if (result.IsSuccess) { successMessage = "Form submitted successfully!"; - formModel = new DynamicFormModel(); + StateHasChanged(); } else { error = result.Errors.First().Message; + StateHasChanged(); } } catch (Exception ex) @@ -217,11 +347,63 @@ } finally { - isLoading = false; + isSubmitting = false; StateHasChanged(); } } + private bool ValidateForm() + { + bool isValid = true; + foreach (var field in formModel.Fields) + { + if (field.IsRequired) + { + switch (field.Type.ToLower()) + { + case "string": + case "email": + if (string.IsNullOrWhiteSpace(field.StringValue)) + { + field.ValidationMessage = "This field is required"; + isValid = false; + } + else if (field.Type.ToLower() == "email" && !IsValidEmail(field.StringValue)) + { + field.ValidationMessage = "Please enter a valid email address"; + isValid = false; + } + break; + + case "number": + if (!field.IsNumberSet) + { + field.ValidationMessage = "This field is required"; + isValid = false; + } + break; + + case "date": + if (field.DateValue == default) + { + field.ValidationMessage = "This field is required"; + isValid = false; + } + break; + } + } + } + return isValid; + } + + private void ResetForm() + { + successMessage = null; + error = null; + formModel = new DynamicFormModel(); + OnInitializedAsync(); + } + private bool IsValidEmail(string email) { try @@ -244,8 +426,10 @@ { public string Name { get; set; } = ""; public string Type { get; set; } = "string"; - public string? Description { get; set; } + public string Description { get; set; } = ""; public bool IsRequired { get; set; } + public string? DefaultValue { get; set; } + public string[]? AllowedValues { get; set; } public string? ValidationMessage { get; set; } private string _stringValue = ""; @@ -254,7 +438,7 @@ get => _stringValue; set { - _stringValue = value; + _stringValue = value ?? ""; ValidationMessage = null; } } @@ -303,9 +487,40 @@ "email" => StringValue, "number" => NumberValue, "boolean" => BoolValue, - "date" => DateValue, + "date" => DateValue.ToString("yyyy-MM-dd"), _ => StringValue }; } + + public void SetDefaultValue() + { + if (string.IsNullOrEmpty(DefaultValue)) return; + + switch (Type.ToLower()) + { + case "string": + case "email": + StringValue = DefaultValue; + break; + case "number": + if (decimal.TryParse(DefaultValue, out decimal numValue)) + { + NumberValue = numValue; + } + break; + case "boolean": + if (bool.TryParse(DefaultValue, out bool boolValue)) + { + BoolValue = boolValue; + } + break; + case "date": + if (DateTime.TryParse(DefaultValue, out DateTime dateValue)) + { + DateValue = dateValue; + } + break; + } + } } } \ No newline at end of file diff --git a/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css b/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css index 9ccee81..4ac6d37 100644 --- a/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css +++ b/Blocktrust.CredentialWorkflow.Web/wwwroot/app.css @@ -588,6 +588,10 @@ video { } } +.pointer-events-none { + pointer-events: none; +} + .invisible { visibility: hidden; } @@ -616,6 +620,11 @@ video { inset: 0px; } +.inset-y-0 { + top: 0px; + bottom: 0px; +} + .bottom-0 { bottom: 0px; } @@ -693,6 +702,10 @@ video { margin-bottom: 1rem; } +.mb-5 { + margin-bottom: 1.25rem; +} + .mb-6 { margin-bottom: 1.5rem; } @@ -701,6 +714,14 @@ video { margin-bottom: 2rem; } +.ml-0 { + margin-left: 0px; +} + +.ml-0\.5 { + margin-left: 0.125rem; +} + .ml-1 { margin-left: 0.25rem; } @@ -805,6 +826,10 @@ video { display: none; } +.h-12 { + height: 3rem; +} + .h-32 { height: 8rem; } @@ -865,6 +890,10 @@ video { width: 25%; } +.w-12 { + width: 3rem; +} + .w-2\/3 { width: 66.666667%; } @@ -942,6 +971,10 @@ video { flex: 1 1 0%; } +.flex-shrink-0 { + flex-shrink: 0; +} + .shrink-0 { flex-shrink: 0; } @@ -1181,6 +1214,10 @@ video { border-width: 2px; } +.border-0 { + border-width: 0px; +} + .border-b { border-bottom-width: 1px; } @@ -1221,11 +1258,6 @@ video { border-color: rgb(55 65 81 / var(--tw-border-opacity)); } -.border-green-400 { - --tw-border-opacity: 1; - border-color: rgb(74 222 128 / var(--tw-border-opacity)); -} - .border-red-400 { --tw-border-opacity: 1; border-color: rgb(248 113 113 / var(--tw-border-opacity)); @@ -1270,6 +1302,15 @@ video { border-color: transparent; } +.border-white { + --tw-border-opacity: 1; + border-color: rgb(255 255 255 / var(--tw-border-opacity)); +} + +.border-b-transparent { + border-bottom-color: transparent; +} + .bg-black { --tw-bg-opacity: 1; background-color: rgb(0 0 0 / var(--tw-bg-opacity)); @@ -1315,11 +1356,6 @@ video { background-color: rgb(220 252 231 / var(--tw-bg-opacity)); } -.bg-green-50 { - --tw-bg-opacity: 1; - background-color: rgb(240 253 244 / var(--tw-bg-opacity)); -} - .bg-green-500 { --tw-bg-opacity: 1; background-color: rgb(34 197 94 / var(--tw-bg-opacity)); @@ -1433,10 +1469,6 @@ video { padding: 1.5rem; } -.p-8 { - padding: 2rem; -} - .px-2 { padding-left: 0.5rem; padding-right: 0.5rem; @@ -1507,6 +1539,19 @@ video { padding-bottom: 2rem; } +.py-2\.5 { + padding-top: 0.625rem; + padding-bottom: 0.625rem; +} + +.pb-5 { + padding-bottom: 1.25rem; +} + +.pl-3 { + padding-left: 0.75rem; +} + .pl-4 { padding-left: 1rem; } @@ -1515,6 +1560,10 @@ video { padding-left: 1.5rem; } +.pr-10 { + padding-right: 2.5rem; +} + .pr-4 { padding-right: 1rem; } @@ -1523,8 +1572,12 @@ video { padding-top: 0.75rem; } -.pt-4 { - padding-top: 1rem; +.pt-5 { + padding-top: 1.25rem; +} + +.pr-3 { + padding-right: 0.75rem; } .text-left { @@ -1693,6 +1746,11 @@ video { color: rgb(126 34 206 / var(--tw-text-opacity)); } +.text-red-400 { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); +} + .text-red-50 { --tw-text-opacity: 1; color: rgb(254 242 242 / var(--tw-text-opacity)); @@ -1805,11 +1863,26 @@ video { box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } +.ring-1 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); +} + +.ring-inset { + --tw-ring-inset: inset; +} + .ring-slate-500 { --tw-ring-opacity: 1; --tw-ring-color: rgb(100 116 139 / var(--tw-ring-opacity)); } +.ring-gray-300 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); +} + .transition { transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; @@ -1902,6 +1975,16 @@ a { height: calc(100vh - 65px); } +.placeholder\:text-gray-400::-moz-placeholder { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); +} + +.placeholder\:text-gray-400::placeholder { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); +} + .hover\:bg-gray-100:hover { --tw-bg-opacity: 1; background-color: rgb(243 244 246 / var(--tw-bg-opacity)); @@ -1942,6 +2025,11 @@ a { background-color: rgb(71 85 105 / var(--tw-bg-opacity)); } +.hover\:bg-slate-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(51 65 85 / var(--tw-bg-opacity)); +} + .hover\:text-blue-700:hover { --tw-text-opacity: 1; color: rgb(29 78 216 / var(--tw-text-opacity)); @@ -2026,18 +2114,16 @@ a { outline-offset: 2px; } -.focus\:ring-1:focus { - --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); - --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); - box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); -} - .focus\:ring-2:focus { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } +.focus\:ring-inset:focus { + --tw-ring-inset: inset; +} + .focus\:ring-gray-300:focus { --tw-ring-opacity: 1; --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); @@ -2058,10 +2144,23 @@ a { --tw-ring-color: rgb(100 116 139 / var(--tw-ring-opacity)); } +.focus\:ring-slate-600:focus { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(71 85 105 / var(--tw-ring-opacity)); +} + .focus\:ring-offset-2:focus { --tw-ring-offset-width: 2px; } +.disabled\:cursor-not-allowed:disabled { + cursor: not-allowed; +} + +.disabled\:opacity-50:disabled { + opacity: 0.5; +} + .group:hover .group-hover\:visible { visibility: visible; } @@ -2075,10 +2174,19 @@ a { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .sm\:px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + .sm\:text-sm { font-size: 0.875rem; line-height: 1.25rem; } + + .sm\:leading-6 { + line-height: 1.5rem; + } } @media (min-width: 768px) {