From ee55332efe203f7ce6a251b92441a90aa19c83b4 Mon Sep 17 00:00:00 2001 From: Nguyen Thien Tu <15050790+thientu995@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:57:12 +0700 Subject: [PATCH 1/9] Add Form builder --- .../Constants/ConfigSettingUTPro.cs | 4 +- .../Controllers/SimpleFormApiController.cs | 65 +++ .../Controllers/SimpleFormSubmitController.cs | 28 + .../Migrations/SimpleFormMigration.cs | 130 +++++ .../Models/FormModels.cs | 131 +++++ .../Services/SimpleFormService.cs | 217 ++++++++ .../ViewComponents/SimpleFormViewComponent.cs | 18 + .../uTPro.Feature.SimpleForm.csproj | 13 + .../uTPro.Feature/uTPro.Feature.csproj | 4 + .../App_Plugins/simple-form/index.js | 500 ++++++++++++++++++ .../App_Plugins/simple-form/lang/en-us.js | 5 + .../simple-form/umbraco-package.json | 31 ++ .../Views/Partials/SimpleForm/Default.cshtml | 165 ++++++ .../Partials/SimpleForm/Fields/README.md | 65 +++ .../SimpleForm/Fields/_Default.cshtml | 26 + .../SimpleForm/Fields/checkbox.cshtml | 31 ++ .../Partials/SimpleForm/Fields/hidden.cshtml | 2 + .../Partials/SimpleForm/Fields/radio.cshtml | 26 + .../Partials/SimpleForm/Fields/select.cshtml | 22 + .../SimpleForm/Fields/textarea.cshtml | 17 + .../blockgrid/Components/ContactForm.cshtml | 2 + .../appsettings.Development.json | 9 + .../uTPro.Project.Web/appsettings.json | 1 + .../uTPro.Project.Web.csproj | 4 + uTPro/uTPro.sln | 7 + 25 files changed, 1521 insertions(+), 2 deletions(-) create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormApiController.cs create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormSubmitController.cs create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/index.js create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/lang/en-us.js create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/umbraco-package.json create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/README.md create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/_Default.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/checkbox.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/hidden.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/radio.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/select.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/textarea.cshtml diff --git a/uTPro/Common/uTPro.Common/Constants/ConfigSettingUTPro.cs b/uTPro/Common/uTPro.Common/Constants/ConfigSettingUTPro.cs index fd3fb3b..b4cc701 100644 --- a/uTPro/Common/uTPro.Common/Constants/ConfigSettingUTPro.cs +++ b/uTPro/Common/uTPro.Common/Constants/ConfigSettingUTPro.cs @@ -13,11 +13,11 @@ public struct Backoffice public struct ListRememberLanguage { - public const string Key = KeyPath + ":RememberLanguage"; + public const string Key = Backoffice.Key + ":RememberLanguage"; public const string Enabled = Key + ":Enabled"; public struct ListExludeRequestLanguage { - public const string Key = KeyPath + ":ListExludeRequestLanguage"; + public const string Key = ListRememberLanguage.Key + ":ListExludeRequestLanguage"; public const string Enabled = Key + ":Enabled"; public const string Paths = Key + ":Paths"; } diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormApiController.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormApiController.cs new file mode 100644 index 0000000..835b134 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormApiController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Controllers; +using Umbraco.Cms.Api.Management.Routing; +using uTPro.Feature.SimpleForm.Models; +using uTPro.Feature.SimpleForm.Services; + +namespace uTPro.Feature.SimpleForm.Controllers; + +[VersionedApiBackOfficeRoute("utpro/simple-form")] +[ApiExplorerSettings(GroupName = "uTPro Simple Form")] +public class SimpleFormApiController(ISimpleFormService formService) : ManagementApiControllerBase +{ + [HttpPost("list")] + public IActionResult List() => Ok(formService.GetAllForms()); + + [HttpPost("get")] + public IActionResult Get([FromBody] GetFormRequest request) + { + var form = formService.GetForm(request.Id); + return form != null ? Ok(form) : NotFound(new { message = "Form not found" }); + } + + [HttpPost("save")] + public IActionResult Save([FromBody] SaveFormRequest request) + { + var (success, message, id) = formService.SaveForm(request); + return success ? Ok(new { message, id }) : BadRequest(new { message }); + } + + [HttpPost("delete")] + public IActionResult Delete([FromBody] DeleteFormRequest request) + { + var (success, message) = formService.DeleteForm(request.Id); + return success ? Ok(new { message }) : BadRequest(new { message }); + } + + [HttpPost("submissions")] + public IActionResult Submissions([FromBody] SubmissionListRequest request) + => Ok(formService.GetSubmissions(request.FormId, request.Skip, request.Take)); + + [HttpPost("delete-submission")] + public IActionResult DeleteSubmission([FromBody] DeleteFormRequest request) + { + var (success, message) = formService.DeleteSubmission(request.Id); + return success ? Ok(new { message }) : BadRequest(new { message }); + } + + [HttpPost("field-types")] + public IActionResult FieldTypes() => Ok(new[] + { + new { type = "text", label = "Text Input" }, + new { type = "email", label = "Email" }, + new { type = "tel", label = "Phone" }, + new { type = "number", label = "Number" }, + new { type = "textarea", label = "Text Area" }, + new { type = "select", label = "Dropdown" }, + new { type = "checkbox", label = "Checkbox" }, + new { type = "radio", label = "Radio Buttons" }, + new { type = "file", label = "File Upload" }, + new { type = "hidden", label = "Hidden Field" }, + new { type = "date", label = "Date Picker" }, + new { type = "url", label = "URL" }, + new { type = "password", label = "Password" }, + }); +} diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormSubmitController.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormSubmitController.cs new file mode 100644 index 0000000..f860c41 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormSubmitController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using uTPro.Feature.SimpleForm.Models; +using uTPro.Feature.SimpleForm.Services; + +namespace uTPro.Feature.SimpleForm.Controllers; + +[ApiController] +[Route("api/utpro/simple-form")] +public class SimpleFormSubmitController(ISimpleFormService formService) : ControllerBase +{ + [HttpPost("submit")] + public IActionResult Submit([FromBody] SubmitFormRequest request) + { + var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); + var ua = HttpContext.Request.Headers.UserAgent.ToString(); + var (success, message) = formService.SubmitForm(request.Alias, request.Data, ip, ua); + return success ? Ok(new { message }) : BadRequest(new { message }); + } + + [HttpGet("render/{alias}")] + public IActionResult RenderForm(string alias) + { + var form = formService.GetFormByAlias(alias); + if (form == null || !form.IsEnabled) return NotFound(new { message = "Form not found" }); + return Ok(form); + } +} diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs new file mode 100644 index 0000000..4a2a30e --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs @@ -0,0 +1,130 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.Migrations; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Migrations; +using Umbraco.Cms.Infrastructure.Migrations.Upgrade; +using Umbraco.Cms.Infrastructure.Scoping; + +namespace uTPro.Feature.SimpleForm.Migrations; + +public class CreateSimpleFormTables : MigrationBase +{ + public CreateSimpleFormTables(IMigrationContext context) : base(context) { } + + protected override void Migrate() + { + // Drop incomplete tables from failed previous migration + if (TableExists("utpro_SimpleFormSubmission")) + Delete.Table("utpro_SimpleFormSubmission").Do(); + if (TableExists("utpro_SimpleForm")) + Delete.Table("utpro_SimpleForm").Do(); + + Create.Table("utpro_SimpleForm") + .WithColumn("Id").AsInt32().NotNullable().Identity().PrimaryKey("PK_utpro_SimpleForm") + .WithColumn("Name").AsString(255).NotNullable() + .WithColumn("Alias").AsString(255).NotNullable().Unique("IX_utpro_SimpleForm_Alias") + .WithColumn("FieldsJson").AsCustom("NTEXT").Nullable() + .WithColumn("SuccessMessage").AsString(1000).Nullable() + .WithColumn("RedirectUrl").AsString(500).Nullable() + .WithColumn("EmailTo").AsString(500).Nullable() + .WithColumn("EmailSubject").AsString(500).Nullable() + .WithColumn("StoreSubmissions").AsBoolean().WithDefaultValue(true) + .WithColumn("IsEnabled").AsBoolean().WithDefaultValue(true) + .WithColumn("CreatedUtc").AsDateTime().NotNullable() + .WithColumn("UpdatedUtc").AsDateTime().NotNullable() + .Do(); + + Create.Table("utpro_SimpleFormSubmission") + .WithColumn("Id").AsInt32().NotNullable().Identity().PrimaryKey("PK_utpro_SimpleFormSubmission") + .WithColumn("FormId").AsInt32().NotNullable() + .WithColumn("DataJson").AsCustom("NTEXT").Nullable() + .WithColumn("IpAddress").AsString(100).Nullable() + .WithColumn("UserAgent").AsString(500).Nullable() + .WithColumn("CreatedUtc").AsDateTime().NotNullable() + .Do(); + } +} + +public class RunSimpleFormMigration : IComposer +{ + public void Compose(IUmbracoBuilder builder) + { + builder.AddNotificationAsyncHandler(); + } +} + +public class SimpleFormMigrationHandler + : Umbraco.Cms.Core.Events.INotificationAsyncHandler +{ + private readonly IMigrationPlanExecutor _migrationPlanExecutor; + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly IKeyValueService _keyValueService; + private readonly IRuntimeState _runtimeState; + + public SimpleFormMigrationHandler( + ICoreScopeProvider coreScopeProvider, + IMigrationPlanExecutor migrationPlanExecutor, + IKeyValueService keyValueService, + IRuntimeState runtimeState) + { + _coreScopeProvider = coreScopeProvider; + _migrationPlanExecutor = migrationPlanExecutor; + _keyValueService = keyValueService; + _runtimeState = runtimeState; + } + + public Task HandleAsync(Umbraco.Cms.Core.Notifications.UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken) + { + if (_runtimeState.Level < RuntimeLevel.Run) return Task.CompletedTask; + + var plan = new MigrationPlan("uTPro.SimpleForm"); + plan.From(string.Empty) + .To("simpleform-002") + .To("simpleform-003-seed"); + + var upgrader = new Upgrader(plan); + upgrader.Execute(_migrationPlanExecutor, _coreScopeProvider, _keyValueService); + + return Task.CompletedTask; + } +} + +public class SeedContactForm : MigrationBase +{ + public SeedContactForm(IMigrationContext context) : base(context) { } + + protected override void Migrate() + { + var now = DateTime.UtcNow; + var fieldsJson = @"[ + {""id"":""f1"",""type"":""text"",""label"":""Name"",""name"":""name"",""placeholder"":""Name"",""required"":true,""sortOrder"":0,""colSpan"":1,""validationMessage"":""Please enter your name""}, + {""id"":""f2"",""type"":""email"",""label"":""Email"",""name"":""email"",""placeholder"":""Email"",""required"":true,""sortOrder"":1,""colSpan"":1,""validationMessage"":""Please enter a valid email""}, + {""id"":""f3"",""type"":""textarea"",""label"":""Message"",""name"":""message"",""placeholder"":""Message"",""required"":true,""sortOrder"":2,""colSpan"":2,""validationMessage"":""Please enter your message""} +]"; + + // Only seed if no form with this alias exists + var existing = Context.Database.ExecuteScalar( + "SELECT COUNT(*) FROM utpro_SimpleForm WHERE Alias = @0", "contact-us"); + + if (existing == 0) + { + Context.Database.Execute(@" + INSERT INTO utpro_SimpleForm (Name, Alias, FieldsJson, SuccessMessage, RedirectUrl, EmailTo, EmailSubject, StoreSubmissions, IsEnabled, CreatedUtc, UpdatedUtc) + VALUES (@0, @1, @2, @3, @4, @5, @6, @7, @8, @9, @10)", + "Contact Us", + "contact-us", + fieldsJson, + "Thank you for contacting us! We will get back to you soon.", + "", + "", + "New Contact Form Submission", + true, + true, + now, + now); + } + } +} diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs new file mode 100644 index 0000000..bab5fde --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs @@ -0,0 +1,131 @@ +using NPoco; + +namespace uTPro.Feature.SimpleForm.Models; + +// ── Database DTOs ── + +[TableName("utpro_SimpleForm")] +[PrimaryKey("Id", AutoIncrement = true)] +public class SimpleFormDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Alias { get; set; } = string.Empty; + public string? FieldsJson { get; set; } + public string? SuccessMessage { get; set; } + public string? RedirectUrl { get; set; } + public string? EmailTo { get; set; } + public string? EmailSubject { get; set; } + public bool StoreSubmissions { get; set; } = true; + public bool IsEnabled { get; set; } = true; + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } +} + +[TableName("utpro_SimpleFormSubmission")] +[PrimaryKey("Id", AutoIncrement = true)] +public class SimpleFormSubmissionDto +{ + public int Id { get; set; } + public int FormId { get; set; } + public string? DataJson { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public DateTime CreatedUtc { get; set; } +} + +// ── API ViewModels ── + +public class FormViewModel +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Alias { get; set; } = string.Empty; + public List Fields { get; set; } = []; + public string? SuccessMessage { get; set; } + public string? RedirectUrl { get; set; } + public string? EmailTo { get; set; } + public string? EmailSubject { get; set; } + public bool StoreSubmissions { get; set; } = true; + public bool IsEnabled { get; set; } = true; + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } +} + +public class FormFieldViewModel +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Type { get; set; } = "text"; + public string Label { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Placeholder { get; set; } + public string? CssClass { get; set; } + public bool Required { get; set; } + public string? Validation { get; set; } + public string? ValidationMessage { get; set; } + public string? DefaultValue { get; set; } + public List? Options { get; set; } + public int SortOrder { get; set; } + /// 1 = half width (default), 2 = full width in 2-col layout + public int ColSpan { get; set; } = 1; + /// Extra attributes JSON for custom field types (e.g. {"siteKey":"xxx"} for turnstile) + public Dictionary? Attributes { get; set; } +} + +public class OptionItem +{ + public string Text { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; +} + +public class SaveFormRequest +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Alias { get; set; } = string.Empty; + public List Fields { get; set; } = []; + public string? SuccessMessage { get; set; } + public string? RedirectUrl { get; set; } + public string? EmailTo { get; set; } + public string? EmailSubject { get; set; } + public bool StoreSubmissions { get; set; } = true; + public bool IsEnabled { get; set; } = true; +} + +public class DeleteFormRequest +{ + public int Id { get; set; } +} + +public class GetFormRequest +{ + public int Id { get; set; } +} + +public class SubmitFormRequest +{ + public string Alias { get; set; } = string.Empty; + public Dictionary Data { get; set; } = []; +} + +public class SubmissionViewModel +{ + public int Id { get; set; } + public int FormId { get; set; } + public Dictionary Data { get; set; } = []; + public string? IpAddress { get; set; } + public DateTime CreatedUtc { get; set; } +} + +public class SubmissionListRequest +{ + public int FormId { get; set; } + public int Skip { get; set; } = 0; + public int Take { get; set; } = 20; +} + +public class PagedResult +{ + public IEnumerable Items { get; set; } = []; + public long Total { get; set; } +} diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs new file mode 100644 index 0000000..c0dce63 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs @@ -0,0 +1,217 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Infrastructure.Scoping; +using uTPro.Feature.SimpleForm.Models; + +namespace uTPro.Feature.SimpleForm.Services; + +class DISimpleFormService : IComposer +{ + public void Compose(IUmbracoBuilder builder) + => builder.Services.AddScoped(); +} + +public interface ISimpleFormService +{ + List GetAllForms(); + FormViewModel? GetForm(int id); + FormViewModel? GetFormByAlias(string alias); + (bool Success, string Message, int Id) SaveForm(SaveFormRequest request); + (bool Success, string Message) DeleteForm(int id); + (bool Success, string Message) SubmitForm(string alias, Dictionary data, string? ip, string? ua); + PagedResult GetSubmissions(int formId, int skip, int take); + (bool Success, string Message) DeleteSubmission(int id); +} + +internal class SimpleFormService(IScopeProvider scopeProvider, ILogger logger) : ISimpleFormService +{ + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + public List GetAllForms() + { + using var scope = scopeProvider.CreateScope(autoComplete: true); + var dtos = scope.Database.Fetch("SELECT * FROM utpro_SimpleForm ORDER BY Name"); + return dtos.Select(MapToViewModel).ToList(); + } + + public FormViewModel? GetForm(int id) + { + using var scope = scopeProvider.CreateScope(autoComplete: true); + var dto = scope.Database.SingleOrDefault("SELECT * FROM utpro_SimpleForm WHERE Id = @0", id); + return dto == null ? null : MapToViewModel(dto); + } + + public FormViewModel? GetFormByAlias(string alias) + { + using var scope = scopeProvider.CreateScope(autoComplete: true); + var dto = scope.Database.SingleOrDefault("SELECT * FROM utpro_SimpleForm WHERE Alias = @0", alias); + return dto == null ? null : MapToViewModel(dto); + } + + public (bool Success, string Message, int Id) SaveForm(SaveFormRequest request) + { + try + { + using var scope = scopeProvider.CreateScope(autoComplete: true); + var db = scope.Database; + var now = DateTime.UtcNow; + var fieldsJson = JsonSerializer.Serialize(request.Fields, JsonOpts); + + if (request.Id > 0) + { + var existing = db.SingleOrDefault("SELECT * FROM utpro_SimpleForm WHERE Id = @0", request.Id); + if (existing == null) return (false, "Form not found", 0); + + existing.Name = request.Name; + existing.Alias = request.Alias; + existing.FieldsJson = fieldsJson; + existing.SuccessMessage = request.SuccessMessage; + existing.RedirectUrl = request.RedirectUrl; + existing.EmailTo = request.EmailTo; + existing.EmailSubject = request.EmailSubject; + existing.StoreSubmissions = request.StoreSubmissions; + existing.IsEnabled = request.IsEnabled; + existing.UpdatedUtc = now; + db.Update(existing); + return (true, "Form updated", existing.Id); + } + else + { + var dup = db.SingleOrDefault("SELECT * FROM utpro_SimpleForm WHERE Alias = @0", request.Alias); + if (dup != null) return (false, "Alias already exists", 0); + + var dto = new SimpleFormDto + { + Name = request.Name, + Alias = request.Alias, + FieldsJson = fieldsJson, + SuccessMessage = request.SuccessMessage, + RedirectUrl = request.RedirectUrl, + EmailTo = request.EmailTo, + EmailSubject = request.EmailSubject, + StoreSubmissions = request.StoreSubmissions, + IsEnabled = request.IsEnabled, + CreatedUtc = now, + UpdatedUtc = now + }; + db.Insert(dto); + return (true, "Form created", dto.Id); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error saving form"); + return (false, ex.Message, 0); + } + } + + public (bool Success, string Message) DeleteForm(int id) + { + try + { + using var scope = scopeProvider.CreateScope(autoComplete: true); + scope.Database.Execute("DELETE FROM utpro_SimpleFormSubmission WHERE FormId = @0", id); + scope.Database.Execute("DELETE FROM utpro_SimpleForm WHERE Id = @0", id); + return (true, "Deleted"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error deleting form {Id}", id); + return (false, ex.Message); + } + } + + public (bool Success, string Message) SubmitForm(string alias, Dictionary data, string? ip, string? ua) + { + try + { + using var scope = scopeProvider.CreateScope(autoComplete: true); + var form = scope.Database.SingleOrDefault("SELECT * FROM utpro_SimpleForm WHERE Alias = @0", alias); + if (form == null) return (false, "Form not found"); + if (!form.IsEnabled) return (false, "Form is disabled"); + + // Validate required fields + var fields = string.IsNullOrEmpty(form.FieldsJson) + ? [] : JsonSerializer.Deserialize>(form.FieldsJson, JsonOpts) ?? []; + + foreach (var f in fields.Where(f => f.Required)) + { + if (!data.TryGetValue(f.Name, out var val) || string.IsNullOrWhiteSpace(val)) + return (false, $"Field '{f.Label}' is required"); + } + + if (form.StoreSubmissions) + { + var sub = new SimpleFormSubmissionDto + { + FormId = form.Id, + DataJson = JsonSerializer.Serialize(data, JsonOpts), + IpAddress = ip, + UserAgent = ua?.Length > 500 ? ua[..500] : ua, + CreatedUtc = DateTime.UtcNow + }; + scope.Database.Insert(sub); + } + + return (true, form.SuccessMessage ?? "Thank you for your submission!"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error submitting form {Alias}", alias); + return (false, ex.Message); + } + } + + public PagedResult GetSubmissions(int formId, int skip, int take) + { + using var scope = scopeProvider.CreateScope(autoComplete: true); + var db = scope.Database; + var sql = scope.SqlContext.Sql() + .Select("*").From("utpro_SimpleFormSubmission") + .Where("FormId = @0", formId) + .OrderByDescending("CreatedUtc"); + + var page = db.Page(skip / Math.Max(take, 1) + 1, take, sql); + return new PagedResult + { + Items = page.Items.Select(MapSubmission), + Total = page.TotalItems + }; + } + + public (bool Success, string Message) DeleteSubmission(int id) + { + try + { + using var scope = scopeProvider.CreateScope(autoComplete: true); + scope.Database.Execute("DELETE FROM utpro_SimpleFormSubmission WHERE Id = @0", id); + return (true, "Deleted"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error deleting submission {Id}", id); + return (false, ex.Message); + } + } + + private static FormViewModel MapToViewModel(SimpleFormDto dto) => new() + { + Id = dto.Id, Name = dto.Name, Alias = dto.Alias, + Fields = string.IsNullOrEmpty(dto.FieldsJson) + ? [] : JsonSerializer.Deserialize>(dto.FieldsJson, JsonOpts) ?? [], + SuccessMessage = dto.SuccessMessage, RedirectUrl = dto.RedirectUrl, + EmailTo = dto.EmailTo, EmailSubject = dto.EmailSubject, + StoreSubmissions = dto.StoreSubmissions, IsEnabled = dto.IsEnabled, + CreatedUtc = dto.CreatedUtc, UpdatedUtc = dto.UpdatedUtc + }; + + private static SubmissionViewModel MapSubmission(SimpleFormSubmissionDto dto) => new() + { + Id = dto.Id, FormId = dto.FormId, + Data = string.IsNullOrEmpty(dto.DataJson) + ? [] : JsonSerializer.Deserialize>(dto.DataJson, JsonOpts) ?? [], + IpAddress = dto.IpAddress, CreatedUtc = dto.CreatedUtc + }; +} diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs new file mode 100644 index 0000000..effdf60 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc; +using uTPro.Feature.SimpleForm.Services; + +namespace uTPro.Feature.SimpleForm.ViewComponents; + +public class SimpleFormViewComponent(ISimpleFormService formService) : ViewComponent +{ + public IViewComponentResult Invoke(string alias, string? cssClass = null, string? submitBtnText = null) + { + var form = formService.GetFormByAlias(alias); + if (form == null || !form.IsEnabled) + return Content($""); + + ViewBag.FormCssClass = cssClass ?? ""; + ViewBag.SubmitBtnText = submitBtnText ?? "Submit"; + return View("~/Views/Partials/SimpleForm/Default.cshtml", form); + } +} diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj b/uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj new file mode 100644 index 0000000..832a792 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/uTPro/Feature/uTPro.Feature/uTPro.Feature.csproj b/uTPro/Feature/uTPro.Feature/uTPro.Feature.csproj index 125f4c9..84913c1 100644 --- a/uTPro/Feature/uTPro.Feature/uTPro.Feature.csproj +++ b/uTPro/Feature/uTPro.Feature/uTPro.Feature.csproj @@ -6,4 +6,8 @@ enable + + + + diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/index.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/index.js new file mode 100644 index 0000000..c9d00f8 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/index.js @@ -0,0 +1,500 @@ +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { html, css, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; + +const API = '/umbraco/management/api/v1/utpro/simple-form'; + +export class UtproSimpleFormDashboard extends UmbLitElement { + static properties = { + _view: { type: String, state: true }, + _forms: { type: Array, state: true }, + _loading: { type: Boolean, state: true }, + _editForm: { type: Object, state: true }, + _fieldTypes: { type: Array, state: true }, + _submissions: { type: Array, state: true }, + _subTotal: { type: Number, state: true }, + _subSkip: { type: Number, state: true }, + _viewFormId: { type: Number, state: true }, + _error: { type: String, state: true }, + _success: { type: String, state: true }, + _selectedSubs: { type: Array, state: true }, + _detailSub: { type: Object, state: true }, + }; + #authContext; + constructor() { + super(); + this._view = 'list'; + this._forms = []; + this._loading = false; + this._editForm = null; + this._fieldTypes = []; + this._submissions = []; + this._subTotal = 0; + this._subSkip = 0; + this._viewFormId = 0; + this._error = ''; + this._success = ''; + this._selectedSubs = []; + this._detailSub = null; + this.consumeContext(UMB_AUTH_CONTEXT, (ctx) => { this.#authContext = ctx; }); + } + async connectedCallback() { + super.connectedCallback(); + await this._loadForms(); + await this._loadFieldTypes(); + } + async _getHeaders() { + const config = this.#authContext?.getOpenApiConfiguration(); + const h = {}; + if (config?.token) { const t = await config.token(); if (t) h['Authorization'] = 'Bearer ' + t; } + return { headers: h, credentials: config?.credentials || 'same-origin' }; + } + async _api(url, body = {}) { + const auth = await this._getHeaders(); + const resp = await fetch(url, { + method: 'POST', headers: { ...auth.headers, 'Content-Type': 'application/json' }, + credentials: auth.credentials, body: JSON.stringify(body) + }); + if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.message || 'Failed'); } + return resp.json(); + } + _msg(m, err = false) { + if (err) { this._error = m; this._success = ''; } else { this._success = m; this._error = ''; } + setTimeout(() => { this._error = ''; this._success = ''; }, 3000); + } + async _loadForms() { + this._loading = true; + try { this._forms = await this._api(API + '/list'); } catch (e) { this._msg(e.message, true); } + this._loading = false; + } + async _loadFieldTypes() { + try { this._fieldTypes = await this._api(API + '/field-types'); } catch {} + } + + _newForm() { + this._editForm = { + id: 0, name: '', alias: '', fields: [], + successMessage: 'Thank you!', redirectUrl: '', emailTo: '', emailSubject: '', + storeSubmissions: true, isEnabled: true + }; + this._view = 'edit'; + } + async _editExisting(id) { + try { + const form = await this._api(API + '/get', { id }); + this._editForm = form; + this._view = 'edit'; + } catch (e) { this._msg(e.message, true); } + } + async _saveForm() { + if (!this._editForm.name || !this._editForm.alias) { this._msg('Name and Alias required', true); return; } + try { + const res = await this._api(API + '/save', this._editForm); + this._msg(res.message); + this._editForm.id = res.id; + await this._loadForms(); + } catch (e) { this._msg(e.message, true); } + } + async _deleteForm(id) { + if (!confirm('Delete this form and all submissions?')) return; + try { + await this._api(API + '/delete', { id }); + this._msg('Deleted'); + await this._loadForms(); + if (this._editForm?.id === id) { this._editForm = null; this._view = 'list'; } + } catch (e) { this._msg(e.message, true); } + } + _addField() { + const f = this._editForm; + const idx = f.fields.length; + f.fields = [...f.fields, { + id: crypto.randomUUID?.() || Date.now().toString(36), + type: 'text', label: '', name: 'field_' + idx, + placeholder: '', cssClass: '', required: false, + validation: '', validationMessage: '', defaultValue: '', + options: [], sortOrder: idx, colSpan: 1, attributes: {} + }]; + this.requestUpdate(); + } + _removeField(idx) { + this._editForm.fields = this._editForm.fields.filter((_, i) => i !== idx); + this.requestUpdate(); + } + _moveField(idx, dir) { + const arr = [...this._editForm.fields]; + const newIdx = idx + dir; + if (newIdx < 0 || newIdx >= arr.length) return; + [arr[idx], arr[newIdx]] = [arr[newIdx], arr[idx]]; + arr.forEach((f, i) => f.sortOrder = i); + this._editForm.fields = arr; + this.requestUpdate(); + } + _updateField(idx, key, val) { + this._editForm.fields[idx][key] = val; + this.requestUpdate(); + } + _addOption(idx) { + if (!this._editForm.fields[idx].options) this._editForm.fields[idx].options = []; + this._editForm.fields[idx].options.push({ text: '', value: '' }); + this.requestUpdate(); + } + _removeOption(fIdx, oIdx) { + this._editForm.fields[fIdx].options.splice(oIdx, 1); + this.requestUpdate(); + } + async _viewSubmissions(formId) { + this._viewFormId = formId; + this._subSkip = 0; + this._view = 'submissions'; + await this._loadSubmissions(); + } + async _loadSubmissions() { + try { + const res = await this._api(API + '/submissions', { formId: this._viewFormId, skip: this._subSkip, take: 20 }); + this._submissions = res.items || []; + this._subTotal = res.total || 0; + } catch (e) { this._msg(e.message, true); } + } + async _deleteSubmission(id) { + if (!confirm('Delete this submission?')) return; + try { await this._api(API + '/delete-submission', { id }); this._msg('Deleted'); this._selectedSubs = this._selectedSubs.filter(x => x !== id); await this._loadSubmissions(); } + catch (e) { this._msg(e.message, true); } + } + _toggleSubSelect(id) { + if (this._selectedSubs.includes(id)) this._selectedSubs = this._selectedSubs.filter(x => x !== id); + else this._selectedSubs = [...this._selectedSubs, id]; + } + _toggleSelectAll() { + if (this._selectedSubs.length === this._submissions.length) this._selectedSubs = []; + else this._selectedSubs = this._submissions.map(s => s.id); + } + async _bulkDelete() { + if (!this._selectedSubs.length) return; + if (!confirm('Delete ' + this._selectedSubs.length + ' submissions?')) return; + for (const id of this._selectedSubs) { + try { await this._api(API + '/delete-submission', { id }); } catch {} + } + this._selectedSubs = []; + this._msg('Deleted'); + await this._loadSubmissions(); + } + _exportCsv() { + if (!this._submissions.length) return; + const allKeys = [...new Set(this._submissions.flatMap(s => Object.keys(s.data || {})))]; + const headers = ['Date', 'IP', ...allKeys]; + const rows = this._submissions.map(s => { + const date = new Date(s.createdUtc).toLocaleString(); + const ip = s.ipAddress || ''; + const fields = allKeys.map(k => '"' + (s.data?.[k] || '').replace(/"/g, '""') + '"'); + return ['"' + date + '"', '"' + ip + '"', ...fields].join(','); + }); + const csv = headers.join(',') + '\n' + rows.join('\n'); + const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + const formName = this._forms.find(f => f.id === this._viewFormId)?.alias || 'form'; + a.href = url; a.download = formName + '-submissions.csv'; a.click(); + URL.revokeObjectURL(url); + } + _viewDetail(sub) { this._detailSub = sub; } + _closeDetail() { this._detailSub = null; } + + render() { + return html` + ${this._error ? html`
${this._error}
` : nothing} + ${this._success ? html`
${this._success}
` : nothing} + ${this._view === 'list' ? this._renderList() + : this._view === 'edit' ? this._renderEditor() + : this._renderSubmissions()} + ${this._detailSub ? this._renderDetail() : nothing}`; + } + _renderList() { + return html` + +
+

Form Builder

+ + New Form +
+ ${this._loading ? html`
` : nothing} + ${!this._forms.length && !this._loading ? html`
No forms yet. Create one!
` : nothing} + ${this._forms.length ? html` + + + Name + Alias + Fields + Status + Actions + + ${this._forms.map(f => html` + + this._editExisting(f.id)}>${f.name} + ${f.alias} + ${f.fields?.length || 0} + ${f.isEnabled ? html`Active` : html`Disabled`} + + this._editExisting(f.id)}>Edit + this._viewSubmissions(f.id)}>Submissions + this._deleteForm(f.id)}>Delete + + `)} + ` : nothing} +
`; + } + + _renderEditor() { + const f = this._editForm; + if (!f) return nothing; + const needsOptions = (t) => ['select','radio','checkbox'].includes(t); + return html` + +
+ { this._view = 'list'; }}>← Back +

${f.id ? 'Edit' : 'New'} Form

+ this._saveForm()}>Save Form +
+
+ + + + + + + + +
+
+

Fields

+ this._addField()}>+ Add Field +
+ ${f.fields.map((field, idx) => html` +
+
+ #${idx + 1} + +
+ this._moveField(idx, -1)} ?disabled=${idx === 0}>▲ + this._moveField(idx, 1)} ?disabled=${idx === f.fields.length - 1}>▼ + this._removeField(idx)}>✕ +
+
+
+ + + + + + + + + +
+ ${needsOptions(field.type) ? html` +
+
Options + this._addOption(idx)}>+ Option +
+ ${(field.options || []).map((opt, oIdx) => html` +
+ { opt.text = e.target.value; this.requestUpdate(); }}> + { opt.value = e.target.value; this.requestUpdate(); }}> + this._removeOption(idx, oIdx)}>✕ +
`)} +
` : nothing} +
`)} + ${f.id ? html`
+

Embed Code

+ POST /api/utpro/simple-form/submit { "alias": "${f.alias}", "data": { ... } }
+ GET /api/utpro/simple-form/render/${f.alias} +
` : nothing} +
`; + } + + _renderSubmissions() { + const form = this._forms.find(f => f.id === this._viewFormId); + const formName = form?.name || 'Form'; + const pages = Math.max(1, Math.ceil(this._subTotal / 20)); + const page = Math.floor(this._subSkip / 20) + 1; + const allKeys = [...new Set(this._submissions.flatMap(s => Object.keys(s.data || {})))]; + const allSelected = this._submissions.length > 0 && this._selectedSubs.length === this._submissions.length; + return html` + +
+ { this._view = 'list'; this._selectedSubs = []; }}>← Back +

Submissions: ${formName}

+
+ ${this._subTotal} entries + ${this._submissions.length ? html` + Export CSV + ` : nothing} + ${this._selectedSubs.length ? html` + + Delete (${this._selectedSubs.length}) + + ` : nothing} +
+
+ ${!this._submissions.length ? html`
No submissions yet
` : html` + + + + + + Date + IP + ${allKeys.map(k => html`${k}`)} + Actions + + ${this._submissions.map(s => html` + + + this._toggleSubSelect(s.id)} /> + + ${new Date(s.createdUtc).toLocaleString()} + ${s.ipAddress || ''} + ${allKeys.map(k => html`${s.data?.[k] || ''}`)} + + this._viewDetail(s)} title="View">☰ + this._deleteSubmission(s.id)} title="Delete">✕ + + `)} + + ${this._subTotal > 20 ? html` + ` : nothing} + `} +
`; + } + + _renderDetail() { + const s = this._detailSub; + if (!s) return nothing; + const entries = Object.entries(s.data || {}); + return html` +
{ if (e.target === e.currentTarget) this._closeDetail(); }}> +
+
+

Submission #${s.id}

+ ✕ Close +
+
+
+ Date + ${new Date(s.createdUtc).toLocaleString()} +
+
+ IP Address + ${s.ipAddress || 'N/A'} +
+ ${entries.map(([k, v]) => html` +
+ ${k} + ${v || ''} +
+ `)} +
+ +
+
`; + } + + static styles = css` + :host { display: block; padding: 20px; } + .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; } + .toolbar h2 { margin: 0; font-size: 1.3rem; } + .msg { padding: 8px 14px; border-radius: 4px; margin-bottom: 10px; font-size: 0.9rem; } + .error { background: #fde8e8; color: #c0392b; } + .success { background: #e8fde8; color: #27ae60; } + .loading { display: flex; justify-content: center; padding: 40px; } + .empty { text-align: center; padding: 40px; color: #888; font-style: italic; } + .link { color: var(--uui-color-interactive, #1b264f); cursor: pointer; font-weight: 500; text-decoration: none; } + .link:hover { text-decoration: underline; } + .badge { padding: 2px 8px; border-radius: 10px; font-size: 0.8rem; font-weight: 500; } + .badge.on { background: #e8fde8; color: #27ae60; } + .badge.off { background: #fde8e8; color: #c0392b; } + .action-cell { display: flex; gap: 4px; } + code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 0.85rem; } + uui-table { width: 100%; } + .form-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 12px; + margin-bottom: 20px; padding: 16px; + background: var(--uui-color-surface-alt, #f9f9f9); border-radius: 6px; + } + .form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; font-weight: 500; } + .check-label { flex-direction: row !important; align-items: center; gap: 8px !important; } + .section-header { display: flex; justify-content: space-between; align-items: center; margin: 16px 0 8px; } + .section-header h3 { margin: 0; } + .field-card { + border: 1px solid var(--uui-color-border, #ddd); border-radius: 6px; + margin-bottom: 10px; overflow: hidden; + } + .field-header { + display: flex; align-items: center; gap: 10px; padding: 10px 14px; + background: var(--uui-color-surface-alt, #f4f4f4); + } + .field-num { font-weight: 600; color: #888; min-width: 30px; } + .field-header select { + padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; + font-size: 0.9rem; background: #fff; + } + .field-actions { margin-left: auto; display: flex; gap: 4px; } + .field-body { + display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 14px; + } + .field-body label { display: flex; flex-direction: column; gap: 4px; font-size: 0.8rem; font-weight: 500; } + .options-section { padding: 0 14px 14px; } + .option-row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; } + .option-row uui-input { flex: 1; } + .embed-info { + margin-top: 20px; padding: 14px; background: #f0f4ff; border-radius: 6px; + border: 1px solid #c8d6f0; + } + .embed-info h4 { margin: 0 0 8px; } + .embed-info code { display: block; margin: 4px 0; padding: 6px 10px; background: #fff; } + .pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; } + .page-info { color: #888; font-size: 0.9rem; } + .toolbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; } + .cell-truncate { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .row-selected { background: var(--uui-color-surface-alt, #f0f4ff) !important; } + .overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); z-index: 9999; + display: flex; justify-content: center; align-items: center; + } + .detail-panel { + background: var(--uui-color-surface, #fff); border-radius: 8px; + width: 600px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + } + .detail-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid #e0e0e0; + } + .detail-header h3 { margin: 0; } + .detail-body { padding: 20px; overflow-y: auto; flex: 1; } + .detail-row { + display: flex; padding: 10px 0; border-bottom: 1px solid #f0f0f0; + } + .detail-label { font-weight: 600; min-width: 120px; color: #555; font-size: 0.9rem; } + .detail-value { flex: 1; word-break: break-word; white-space: pre-wrap; } + .detail-footer { padding: 12px 20px; border-top: 1px solid #e0e0e0; display: flex; justify-content: flex-end; } + `; +} + +customElements.define('utpro-simple-form-dashboard', UtproSimpleFormDashboard); +export default UtproSimpleFormDashboard; diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/lang/en-us.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/lang/en-us.js new file mode 100644 index 0000000..1818b14 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/lang/en-us.js @@ -0,0 +1,5 @@ +export default { + simpleForm: { + title: "Form Builder", + } +} diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/umbraco-package.json b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/umbraco-package.json new file mode 100644 index 0000000..54c40f5 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/umbraco-package.json @@ -0,0 +1,31 @@ +{ + "name": "uTPro.SimpleForm", + "version": "1.0.0", + "extensions": [ + { + "type": "dashboard", + "alias": "uTPro.SimpleForm.Dashboard", + "name": "Simple Form Builder", + "element": "/App_Plugins/simple-form/index.js", + "elementName": "utpro-simple-form-dashboard", + "weight": 15, + "meta": { + "label": "#simpleForm_title", + "pathname": "simple-form" + }, + "conditions": [ + { + "alias": "Umb.Condition.SectionAlias", + "match": "Umb.Section.Settings" + } + ] + }, + { + "type": "localization", + "alias": "uTPro.SimpleForm.Localize.EnUS", + "name": "Simple Form English", + "js": "/App_Plugins/simple-form/lang/en-us.js", + "meta": { "culture": "en-US" } + } + ] +} diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml new file mode 100644 index 0000000..e07ea49 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml @@ -0,0 +1,165 @@ +@model uTPro.Feature.SimpleForm.Models.FormViewModel +@{ + var formId = "sf-" + Model.Alias; + var cssClass = ViewBag.FormCssClass as string ?? ""; + var btnText = ViewBag.SubmitBtnText as string ?? "SEND"; + var showReset = ViewBag.ShowReset as bool? ?? true; + var resetText = ViewBag.ResetBtnText as string ?? "RESET"; +} + +
+ + +
+ @foreach (var field in Model.Fields.OrderBy(f => f.SortOrder)) + { + var span = field.ColSpan >= 2 ? "sf-col-full" : "sf-col-half"; + var fieldCss = field.CssClass ?? ""; + +
+ @{ + // Try custom partial first: Fields/{Type}.cshtml + // Fallback to Fields/_Default.cshtml + var customPartial = $"~/Views/Partials/SimpleForm/Fields/{field.Type}.cshtml"; + var fallbackPartial = "~/Views/Partials/SimpleForm/Fields/_Default.cshtml"; + } + @if (System.IO.File.Exists( + System.IO.Path.Combine( + ViewContext.HttpContext.RequestServices + .GetRequiredService() + .ContentRootPath, + customPartial.Replace("~/", "").Replace("/", System.IO.Path.DirectorySeparatorChar.ToString())))) + { + @await Html.PartialAsync(customPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId } }) + } + else + { + @await Html.PartialAsync(fallbackPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId } }) + } +
+ } +
+ +
+ + @if (showReset) + { + + } +
+ +
+ + + + diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/README.md b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/README.md new file mode 100644 index 0000000..50c7a1b --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/README.md @@ -0,0 +1,65 @@ +# SimpleForm Custom Field Types + +To add a custom field type (e.g. Cloudflare Turnstile, encrypted field, rating stars, etc.): + +## 1. Create a partial view + +Create a `.cshtml` file in this folder named after your field type: + +``` +Fields/turnstile.cshtml +Fields/rating.cshtml +Fields/encrypted.cshtml +``` + +## 2. Partial view receives + +- `@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel` +- `ViewData["FormId"]` — the form element ID (string) +- `Model.Attributes` — Dictionary for custom config (e.g. siteKey, theme) + +## 3. Example: Cloudflare Turnstile + +Create `Fields/turnstile.cshtml`: + +```html +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ var siteKey = Model.Attributes?.GetValueOrDefault("siteKey") ?? ""; } + +
+ + + + +``` + +Then in backoffice Form Builder, add a field with: +- Type: `turnstile` +- Attributes: `siteKey` = `0x4AAAAAAA...` + +## 4. JS Hooks + +You can hook into form submission from any custom field: + +```js +// Called before submit — return false to cancel, or object to merge extra data +window.__sfBeforeSubmit = async function(alias, data, formEl) { + // e.g. validate turnstile token + if (!data['cf-turnstile']) return false; + return data; +}; + +// Called after successful submit +window.__sfAfterSubmit = function(alias, success, result) { + // e.g. reset turnstile widget +}; +``` + +## 5. Register in backoffice (optional) + +To make your custom type appear in the backoffice field type dropdown, +add it via the `/field-types` API endpoint in `SimpleFormApiController.cs`. diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/_Default.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/_Default.cshtml new file mode 100644 index 0000000..9cdc201 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/_Default.cshtml @@ -0,0 +1,26 @@ +@* ═══════════════════════════════════════════════════════════════ + DEFAULT FIELD PARTIAL — handles: text, email, tel, number, date, url, password + To add a CUSTOM field type, create a new file in this folder: + Fields/{YourType}.cshtml (e.g. Fields/turnstile.cshtml) + It receives: Model = FormFieldViewModel, ViewData["FormId"] = form element id +═══════════════════════════════════════════════════════════════ *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; +} + +@if (Model.Type != "hidden") +{ + +} + + + diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/checkbox.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/checkbox.cshtml new file mode 100644 index 0000000..36400fe --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/checkbox.cshtml @@ -0,0 +1,31 @@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; +} + +@if (Model.Options != null && Model.Options.Any()) +{ + +
+ @foreach (var opt in Model.Options) + { + var cbId = fieldId + "-" + opt.Value; + + } +
+} +else +{ + +} + diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/hidden.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/hidden.cshtml new file mode 100644 index 0000000..c7a5db4 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/hidden.cshtml @@ -0,0 +1,2 @@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel + diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/radio.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/radio.cshtml new file mode 100644 index 0000000..30a0eb4 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/radio.cshtml @@ -0,0 +1,26 @@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; +} + + +
+ @if (Model.Options != null) + { + @foreach (var opt in Model.Options) + { + var radioId = fieldId + "-" + opt.Value; + var isChecked = opt.Value == Model.DefaultValue; + + } + } +
+ diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/select.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/select.cshtml new file mode 100644 index 0000000..65c71c3 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/select.cshtml @@ -0,0 +1,22 @@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; +} + + + + diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/textarea.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/textarea.cshtml new file mode 100644 index 0000000..fe49e1c --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/textarea.cshtml @@ -0,0 +1,17 @@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; + var rows = Model.Attributes?.GetValueOrDefault("rows") ?? "4"; +} + + + + diff --git a/uTPro/Project/uTPro.Project.Web/Views/uTPro/blockgrid/Components/ContactForm.cshtml b/uTPro/Project/uTPro.Project.Web/Views/uTPro/blockgrid/Components/ContactForm.cshtml index 7ecf005..42ca171 100644 --- a/uTPro/Project/uTPro.Project.Web/Views/uTPro/blockgrid/Components/ContactForm.cshtml +++ b/uTPro/Project/uTPro.Project.Web/Views/uTPro/blockgrid/Components/ContactForm.cshtml @@ -19,6 +19,8 @@ else
+ @await Component.InvokeAsync("SimpleForm", new { alias = "contact-us" }) +
diff --git a/uTPro/Project/uTPro.Project.Web/appsettings.Development.json b/uTPro/Project/uTPro.Project.Web/appsettings.Development.json index 3d1d07e..63198e6 100644 --- a/uTPro/Project/uTPro.Project.Web/appsettings.Development.json +++ b/uTPro/Project/uTPro.Project.Web/appsettings.Development.json @@ -25,6 +25,15 @@ "Backoffice": { "Enabled": true, "Url": "bo.utpro.local" + }, + "RememberLanguage": { + "Enabled": true, + "ListExludeRequestLanguage": { + "Enabled": true, + "Paths": [ + "api" + ] + } } }, "Umbraco": { diff --git a/uTPro/Project/uTPro.Project.Web/appsettings.json b/uTPro/Project/uTPro.Project.Web/appsettings.json index ac8cd4e..3cd01f8 100644 --- a/uTPro/Project/uTPro.Project.Web/appsettings.json +++ b/uTPro/Project/uTPro.Project.Web/appsettings.json @@ -24,6 +24,7 @@ "ListExludeRequestLanguage": { "Enabled": true, "Paths": [ + "api" ] } } diff --git a/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj b/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj index c7aaf16..012858b 100644 --- a/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj +++ b/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj @@ -19,6 +19,9 @@ + + + @@ -28,6 +31,7 @@ + diff --git a/uTPro/uTPro.sln b/uTPro/uTPro.sln index 2a5da96..6127588 100644 --- a/uTPro/uTPro.sln +++ b/uTPro/uTPro.sln @@ -33,6 +33,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Feature", "Feature", "{02EA EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "uTPro.Feature", "Feature\uTPro.Feature\uTPro.Feature.csproj", "{E606C629-3B37-458C-9C80-D555A762E28E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "uTPro.Feature.SimpleForm", "Feature\uTPro.Feature.SimpleForm\uTPro.Feature.SimpleForm.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789ABC}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Project", "Project", "{9C8C527F-5B16-429C-8B61-D129E2D5A503}" EndProject Global @@ -85,6 +87,10 @@ Global {E606C629-3B37-458C-9C80-D555A762E28E}.Debug|Any CPU.Build.0 = Debug|Any CPU {E606C629-3B37-458C-9C80-D555A762E28E}.Release|Any CPU.ActiveCfg = Release|Any CPU {E606C629-3B37-458C-9C80-D555A762E28E}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -101,6 +107,7 @@ Global {2017A7B3-BBB0-4F75-86CA-1280432764A2} = {C47DDDB1-58C4-4FEC-BB0C-1E452FB526E1} {B4167671-FAAC-45F5-AF04-04FF196046FB} = {C47DDDB1-58C4-4FEC-BB0C-1E452FB526E1} {E606C629-3B37-458C-9C80-D555A762E28E} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {C3D4E5F6-A7B8-9012-CDEF-123456789ABC} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EEF559D3-C416-4C76-A0BD-CD923A2994F8} From cad6407ca104bc79e16b36bd15e04f467c50beb0 Mon Sep 17 00:00:00 2001 From: Nguyen Thien Tu <15050790+thientu995@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:21:27 +0700 Subject: [PATCH 2/9] Update form --- .../Controllers/SimpleFormApiController.cs | 77 ++- .../Controllers/SimpleFormSubmitController.cs | 12 + .../Migrations/SimpleFormMigration.cs | 144 ++--- .../Models/FormModels.cs | 34 +- .../Services/SimpleFormService.cs | 122 ++++- .../ViewComponents/SimpleFormViewComponent.cs | 49 +- .../uTPro.Feature.SimpleForm.csproj | 4 + .../App_Plugins/simple-form/api.js | 29 + .../App_Plugins/simple-form/index.js | 511 ++++++------------ .../App_Plugins/simple-form/styles.js | 205 +++++++ .../simple-form/views/detail-view.js | 44 ++ .../simple-form/views/editor-view.js | 341 ++++++++++++ .../simple-form/views/entries-view.js | 100 ++++ .../simple-form/views/list-view.js | 53 ++ .../Views/Partials/SimpleForm/Default.cshtml | 152 +----- .../SimpleForm/Fields/_Default.cshtml | 35 +- .../Partials/SimpleForm/Fields/accept.cshtml | 22 + .../Partials/SimpleForm/Fields/color.cshtml | 13 + .../Partials/SimpleForm/Fields/div.cshtml | 13 + .../Partials/SimpleForm/Fields/range.cshtml | 19 + .../Partials/SimpleForm/Fields/step.cshtml | 10 + .../SimpleForm/Fields/textarea.cshtml | 16 +- .../Partials/SimpleForm/Fields/time.cshtml | 20 + .../Views/globalPageError.cshtml | 4 +- .../blockgrid/Components/ContactForm.cshtml | 4 +- .../appsettings.Development.json | 2 +- .../uTPro.Project.Web.csproj | 8 + .../wwwroot/css/simple-form.css | 25 + .../wwwroot/css/uTPro/main.css | 5 - .../wwwroot/js/simple-form.js | 94 ++++ 30 files changed, 1562 insertions(+), 605 deletions(-) create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/api.js create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/styles.js create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/detail-view.js create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/editor-view.js create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/entries-view.js create mode 100644 uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/list-view.js create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/accept.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/color.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/div.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/range.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/step.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/time.cshtml create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/css/simple-form.css create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/js/simple-form.js diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormApiController.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormApiController.cs index 835b134..5affdde 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormApiController.cs +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormApiController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Controllers; using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core.Security; using uTPro.Feature.SimpleForm.Models; using uTPro.Feature.SimpleForm.Services; @@ -8,8 +9,21 @@ namespace uTPro.Feature.SimpleForm.Controllers; [VersionedApiBackOfficeRoute("utpro/simple-form")] [ApiExplorerSettings(GroupName = "uTPro Simple Form")] -public class SimpleFormApiController(ISimpleFormService formService) : ManagementApiControllerBase +public class SimpleFormApiController( + ISimpleFormService formService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) : ManagementApiControllerBase { + // ── Permissions ── + + [HttpPost("permissions")] + public IActionResult Permissions() => Ok(new + { + isAdmin = IsCurrentUserAdmin(), + canViewSensitive = CanCurrentUserViewSensitiveData() + }); + + // ── Forms (read: all users, write: admin only) ── + [HttpPost("list")] public IActionResult List() => Ok(formService.GetAllForms()); @@ -23,6 +37,9 @@ public IActionResult Get([FromBody] GetFormRequest request) [HttpPost("save")] public IActionResult Save([FromBody] SaveFormRequest request) { + if (!IsCurrentUserAdmin()) + return Unauthorized(new { message = "Only administrators can edit forms" }); + var (success, message, id) = formService.SaveForm(request); return success ? Ok(new { message, id }) : BadRequest(new { message }); } @@ -30,24 +47,41 @@ public IActionResult Save([FromBody] SaveFormRequest request) [HttpPost("delete")] public IActionResult Delete([FromBody] DeleteFormRequest request) { + if (!IsCurrentUserAdmin()) + return Unauthorized(new { message = "Only administrators can delete forms" }); + var (success, message) = formService.DeleteForm(request.Id); return success ? Ok(new { message }) : BadRequest(new { message }); } - [HttpPost("submissions")] - public IActionResult Submissions([FromBody] SubmissionListRequest request) - => Ok(formService.GetSubmissions(request.FormId, request.Skip, request.Take)); + // ── Entries (view: all users, delete: admin only) ── + + [HttpPost("entries")] + public IActionResult Entries([FromBody] EntryListRequest request) + { + var canViewSensitive = CanCurrentUserViewSensitiveData(); + return Ok(formService.GetEntries( + request.FormId, request.Skip, request.Take, canViewSensitive, + request.Search, request.DateFrom, request.DateTo)); + } - [HttpPost("delete-submission")] - public IActionResult DeleteSubmission([FromBody] DeleteFormRequest request) + [HttpPost("delete-entry")] + public IActionResult DeleteEntry([FromBody] DeleteFormRequest request) { - var (success, message) = formService.DeleteSubmission(request.Id); + if (!IsCurrentUserAdmin()) + return Unauthorized(new { message = "Only administrators can delete entries" }); + + var (success, message) = formService.DeleteEntry(request.Id); return success ? Ok(new { message }) : BadRequest(new { message }); } + // ── Field types ── + [HttpPost("field-types")] public IActionResult FieldTypes() => Ok(new[] { + new { type = "div", label = "Content Block" }, + new { type = "step", label = "Form Step" }, new { type = "text", label = "Text Input" }, new { type = "email", label = "Email" }, new { type = "tel", label = "Phone" }, @@ -59,7 +93,36 @@ public IActionResult FieldTypes() => Ok(new[] new { type = "file", label = "File Upload" }, new { type = "hidden", label = "Hidden Field" }, new { type = "date", label = "Date Picker" }, + new { type = "time", label = "Time Picker" }, new { type = "url", label = "URL" }, new { type = "password", label = "Password" }, + new { type = "accept", label = "Accept / Terms" }, + new { type = "range", label = "Range Slider" }, + new { type = "color", label = "Color Picker" }, }); + + // ── Helpers ── + + private bool IsCurrentUserAdmin() + { + try + { + var user = backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + return user?.IsAdmin() == true; + } + catch { return false; } + } + + private bool CanCurrentUserViewSensitiveData() + { + try + { + var user = backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; + if (user == null) return false; + if (user.IsAdmin()) return true; + return user.Groups.Any(g => + string.Equals(g.Alias, "sensitiveData", StringComparison.OrdinalIgnoreCase)); + } + catch { return false; } + } } diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormSubmitController.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormSubmitController.cs index f860c41..7e10a87 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormSubmitController.cs +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormSubmitController.cs @@ -23,6 +23,18 @@ public IActionResult RenderForm(string alias) { var form = formService.GetFormByAlias(alias); if (form == null || !form.IsEnabled) return NotFound(new { message = "Form not found" }); + if (!form.EnableRenderApi) return NotFound(new { message = "Render API is disabled for this form" }); return Ok(form); } + + [HttpGet("entries/{alias}")] + public IActionResult PublicEntries(string alias, [FromQuery] int skip = 0, [FromQuery] int take = 20) + { + var form = formService.GetFormByAlias(alias); + if (form == null || !form.IsEnabled) return NotFound(new { message = "Form not found" }); + if (!form.EnableEntriesApi) return NotFound(new { message = "Entries API is disabled for this form" }); + // Public API: sensitive data is always masked + var result = formService.GetEntries(form.Id, skip, take, canViewSensitive: false); + return Ok(result); + } } diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs index 4a2a30e..d980299 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs @@ -9,44 +9,93 @@ namespace uTPro.Feature.SimpleForm.Migrations; -public class CreateSimpleFormTables : MigrationBase +// ── v1: Create all tables with final schema ── + +public class CreateSimpleFormTablesV1 : MigrationBase { - public CreateSimpleFormTables(IMigrationContext context) : base(context) { } + public CreateSimpleFormTablesV1(IMigrationContext context) : base(context) { } protected override void Migrate() { - // Drop incomplete tables from failed previous migration - if (TableExists("utpro_SimpleFormSubmission")) - Delete.Table("utpro_SimpleFormSubmission").Do(); + if (TableExists("utpro_SimpleFormEntry")) + Delete.Table("utpro_SimpleFormEntry").Do(); if (TableExists("utpro_SimpleForm")) Delete.Table("utpro_SimpleForm").Do(); Create.Table("utpro_SimpleForm") - .WithColumn("Id").AsInt32().NotNullable().Identity().PrimaryKey("PK_utpro_SimpleForm") - .WithColumn("Name").AsString(255).NotNullable() - .WithColumn("Alias").AsString(255).NotNullable().Unique("IX_utpro_SimpleForm_Alias") - .WithColumn("FieldsJson").AsCustom("NTEXT").Nullable() - .WithColumn("SuccessMessage").AsString(1000).Nullable() - .WithColumn("RedirectUrl").AsString(500).Nullable() - .WithColumn("EmailTo").AsString(500).Nullable() - .WithColumn("EmailSubject").AsString(500).Nullable() - .WithColumn("StoreSubmissions").AsBoolean().WithDefaultValue(true) - .WithColumn("IsEnabled").AsBoolean().WithDefaultValue(true) - .WithColumn("CreatedUtc").AsDateTime().NotNullable() - .WithColumn("UpdatedUtc").AsDateTime().NotNullable() - .Do(); - - Create.Table("utpro_SimpleFormSubmission") - .WithColumn("Id").AsInt32().NotNullable().Identity().PrimaryKey("PK_utpro_SimpleFormSubmission") - .WithColumn("FormId").AsInt32().NotNullable() - .WithColumn("DataJson").AsCustom("NTEXT").Nullable() - .WithColumn("IpAddress").AsString(100).Nullable() - .WithColumn("UserAgent").AsString(500).Nullable() - .WithColumn("CreatedUtc").AsDateTime().NotNullable() - .Do(); + .WithColumn("Id").AsInt32().NotNullable().Identity().PrimaryKey("PK_utpro_SimpleForm") + .WithColumn("Name").AsString(255).NotNullable() + .WithColumn("Alias").AsString(255).NotNullable().Unique("IX_utpro_SimpleForm_Alias") + .WithColumn("FieldsJson").AsCustom("NTEXT").Nullable() + .WithColumn("SuccessMessage").AsString(1000).Nullable() + .WithColumn("RedirectUrl").AsString(500).Nullable() + .WithColumn("EmailTo").AsString(500).Nullable() + .WithColumn("EmailSubject").AsString(500).Nullable() + .WithColumn("StoreEntries").AsBoolean().WithDefaultValue(true) + .WithColumn("IsEnabled").AsBoolean().WithDefaultValue(true) + .WithColumn("VisibleColumnsJson").AsCustom("NTEXT").Nullable() + .WithColumn("EnableRenderApi").AsBoolean().WithDefaultValue(false) + .WithColumn("EnableEntriesApi").AsBoolean().WithDefaultValue(false) + .WithColumn("CreatedUtc").AsDateTime().NotNullable() + .WithColumn("UpdatedUtc").AsDateTime().NotNullable() + .Do(); + + Create.Table("utpro_SimpleFormEntry") + .WithColumn("Id").AsInt32().NotNullable().Identity().PrimaryKey("PK_utpro_SimpleFormEntry") + .WithColumn("FormId").AsInt32().NotNullable() + .WithColumn("DataJson").AsCustom("NTEXT").Nullable() + .WithColumn("IpAddress").AsString(100).Nullable() + .WithColumn("UserAgent").AsString(500).Nullable() + .WithColumn("CreatedUtc").AsDateTime().NotNullable() + .Do(); + } +} + +// ── v1: Seed default Contact Us form ── + +public class SeedContactFormV1 : MigrationBase +{ + public SeedContactFormV1(IMigrationContext context) : base(context) { } + + protected override void Migrate() + { + var now = DateTime.UtcNow; + var fieldsJson = @"[ + {""id"":""f1"",""type"":""text"",""label"":""Name"",""name"":""name"",""placeholder"":""Name"",""required"":true,""sortOrder"":0,""validationMessage"":""Please enter your name""}, + {""id"":""f2"",""type"":""email"",""label"":""Email"",""name"":""email"",""placeholder"":""Email"",""required"":true,""sortOrder"":1,""validationMessage"":""Please enter a valid email""}, + {""id"":""f3"",""type"":""textarea"",""label"":""Message"",""name"":""message"",""placeholder"":""Message"",""required"":true,""sortOrder"":2,""validationMessage"":""Please enter your message""} +]"; + + var existing = Context.Database.ExecuteScalar( + "SELECT COUNT(*) FROM utpro_SimpleForm WHERE Alias = @0", "contact-us"); + + if (existing == 0) + { + Context.Database.Execute(@" + INSERT INTO utpro_SimpleForm + (Name, Alias, FieldsJson, SuccessMessage, RedirectUrl, EmailTo, EmailSubject, + StoreEntries, IsEnabled, VisibleColumnsJson, EnableRenderApi, EnableEntriesApi, CreatedUtc, UpdatedUtc) + VALUES (@0, @1, @2, @3, @4, @5, @6, @7, @8, @9, @10, @11, @12, @13)", + "Contact Us", + "contact-us", + fieldsJson, + "Thank you for contacting us! We will get back to you soon.", + "", + "", + "New Contact Form Entry", + true, // StoreEntries + true, // IsEnabled + null, // VisibleColumnsJson + false, // EnableRenderApi + false, // EnableEntriesApi + now, + now); + } } } +// ── Migration runner ── + public class RunSimpleFormMigration : IComposer { public void Compose(IUmbracoBuilder builder) @@ -82,8 +131,8 @@ public Task HandleAsync(Umbraco.Cms.Core.Notifications.UmbracoApplicationStarted var plan = new MigrationPlan("uTPro.SimpleForm"); plan.From(string.Empty) - .To("simpleform-002") - .To("simpleform-003-seed"); + .To("simpleform-v1-001-tables") + .To("simpleform-v1-002-seed"); var upgrader = new Upgrader(plan); upgrader.Execute(_migrationPlanExecutor, _coreScopeProvider, _keyValueService); @@ -91,40 +140,3 @@ public Task HandleAsync(Umbraco.Cms.Core.Notifications.UmbracoApplicationStarted return Task.CompletedTask; } } - -public class SeedContactForm : MigrationBase -{ - public SeedContactForm(IMigrationContext context) : base(context) { } - - protected override void Migrate() - { - var now = DateTime.UtcNow; - var fieldsJson = @"[ - {""id"":""f1"",""type"":""text"",""label"":""Name"",""name"":""name"",""placeholder"":""Name"",""required"":true,""sortOrder"":0,""colSpan"":1,""validationMessage"":""Please enter your name""}, - {""id"":""f2"",""type"":""email"",""label"":""Email"",""name"":""email"",""placeholder"":""Email"",""required"":true,""sortOrder"":1,""colSpan"":1,""validationMessage"":""Please enter a valid email""}, - {""id"":""f3"",""type"":""textarea"",""label"":""Message"",""name"":""message"",""placeholder"":""Message"",""required"":true,""sortOrder"":2,""colSpan"":2,""validationMessage"":""Please enter your message""} -]"; - - // Only seed if no form with this alias exists - var existing = Context.Database.ExecuteScalar( - "SELECT COUNT(*) FROM utpro_SimpleForm WHERE Alias = @0", "contact-us"); - - if (existing == 0) - { - Context.Database.Execute(@" - INSERT INTO utpro_SimpleForm (Name, Alias, FieldsJson, SuccessMessage, RedirectUrl, EmailTo, EmailSubject, StoreSubmissions, IsEnabled, CreatedUtc, UpdatedUtc) - VALUES (@0, @1, @2, @3, @4, @5, @6, @7, @8, @9, @10)", - "Contact Us", - "contact-us", - fieldsJson, - "Thank you for contacting us! We will get back to you soon.", - "", - "", - "New Contact Form Submission", - true, - true, - now, - now); - } - } -} diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs index bab5fde..1a48e6c 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using NPoco; namespace uTPro.Feature.SimpleForm.Models; @@ -16,15 +17,18 @@ public class SimpleFormDto public string? RedirectUrl { get; set; } public string? EmailTo { get; set; } public string? EmailSubject { get; set; } - public bool StoreSubmissions { get; set; } = true; + public bool StoreEntries { get; set; } = true; public bool IsEnabled { get; set; } = true; + public string? VisibleColumnsJson { get; set; } + public bool EnableRenderApi { get; set; } + public bool EnableEntriesApi { get; set; } public DateTime CreatedUtc { get; set; } public DateTime UpdatedUtc { get; set; } } -[TableName("utpro_SimpleFormSubmission")] +[TableName("utpro_SimpleFormEntry")] [PrimaryKey("Id", AutoIncrement = true)] -public class SimpleFormSubmissionDto +public class SimpleFormEntryDto { public int Id { get; set; } public int FormId { get; set; } @@ -46,8 +50,11 @@ public class FormViewModel public string? RedirectUrl { get; set; } public string? EmailTo { get; set; } public string? EmailSubject { get; set; } - public bool StoreSubmissions { get; set; } = true; + public bool StoreEntries { get; set; } = true; public bool IsEnabled { get; set; } = true; + public List? VisibleColumns { get; set; } + public bool EnableRenderApi { get; set; } + public bool EnableEntriesApi { get; set; } public DateTime CreatedUtc { get; set; } public DateTime UpdatedUtc { get; set; } } @@ -64,10 +71,13 @@ public class FormFieldViewModel public string? Validation { get; set; } public string? ValidationMessage { get; set; } public string? DefaultValue { get; set; } + /// When true, the field value is encrypted before storage and masked in the backoffice + /// unless the user has the Sensitive Data permission. Defaults to true for "password" type. + public bool IsSensitive { get; set; } public List? Options { get; set; } public int SortOrder { get; set; } - /// 1 = half width (default), 2 = full width in 2-col layout - public int ColSpan { get; set; } = 1; + /// When true, the field is temporarily hidden from the frontend form but kept in the configuration. + public bool IsHidden { get; set; } /// Extra attributes JSON for custom field types (e.g. {"siteKey":"xxx"} for turnstile) public Dictionary? Attributes { get; set; } } @@ -88,8 +98,11 @@ public class SaveFormRequest public string? RedirectUrl { get; set; } public string? EmailTo { get; set; } public string? EmailSubject { get; set; } - public bool StoreSubmissions { get; set; } = true; + public bool StoreEntries { get; set; } = true; public bool IsEnabled { get; set; } = true; + public List? VisibleColumns { get; set; } + public bool EnableRenderApi { get; set; } + public bool EnableEntriesApi { get; set; } } public class DeleteFormRequest @@ -108,7 +121,7 @@ public class SubmitFormRequest public Dictionary Data { get; set; } = []; } -public class SubmissionViewModel +public class EntryViewModel { public int Id { get; set; } public int FormId { get; set; } @@ -117,11 +130,14 @@ public class SubmissionViewModel public DateTime CreatedUtc { get; set; } } -public class SubmissionListRequest +public class EntryListRequest { public int FormId { get; set; } public int Skip { get; set; } = 0; public int Take { get; set; } = 20; + public string? Search { get; set; } + public DateTime? DateFrom { get; set; } + public DateTime? DateTo { get; set; } } public class PagedResult diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs index c0dce63..f2a932d 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Composing; @@ -21,13 +22,21 @@ public interface ISimpleFormService (bool Success, string Message, int Id) SaveForm(SaveFormRequest request); (bool Success, string Message) DeleteForm(int id); (bool Success, string Message) SubmitForm(string alias, Dictionary data, string? ip, string? ua); - PagedResult GetSubmissions(int formId, int skip, int take); - (bool Success, string Message) DeleteSubmission(int id); + PagedResult GetEntries(int formId, int skip, int take, bool canViewSensitive = false, string? search = null, DateTime? dateFrom = null, DateTime? dateTo = null); + (bool Success, string Message) DeleteEntry(int id); } -internal class SimpleFormService(IScopeProvider scopeProvider, ILogger logger) : ISimpleFormService +internal class SimpleFormService( + IScopeProvider scopeProvider, + ILogger logger, + IDataProtectionProvider dataProtectionProvider) : ISimpleFormService { private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + private const string ProtectorPurpose = "uTPro.SimpleForm.SensitiveField"; + private const string EncryptedPrefix = "🔒:"; + private const string MaskedValue = "*****"; + + private IDataProtector Protector => dataProtectionProvider.CreateProtector(ProtectorPurpose); public List GetAllForms() { @@ -71,8 +80,12 @@ public List GetAllForms() existing.RedirectUrl = request.RedirectUrl; existing.EmailTo = request.EmailTo; existing.EmailSubject = request.EmailSubject; - existing.StoreSubmissions = request.StoreSubmissions; + existing.StoreEntries = request.StoreEntries; existing.IsEnabled = request.IsEnabled; + existing.VisibleColumnsJson = request.VisibleColumns != null + ? JsonSerializer.Serialize(request.VisibleColumns, JsonOpts) : null; + existing.EnableRenderApi = request.EnableRenderApi; + existing.EnableEntriesApi = request.EnableEntriesApi; existing.UpdatedUtc = now; db.Update(existing); return (true, "Form updated", existing.Id); @@ -91,8 +104,12 @@ public List GetAllForms() RedirectUrl = request.RedirectUrl, EmailTo = request.EmailTo, EmailSubject = request.EmailSubject, - StoreSubmissions = request.StoreSubmissions, + StoreEntries = request.StoreEntries, IsEnabled = request.IsEnabled, + VisibleColumnsJson = request.VisibleColumns != null + ? JsonSerializer.Serialize(request.VisibleColumns, JsonOpts) : null, + EnableRenderApi = request.EnableRenderApi, + EnableEntriesApi = request.EnableEntriesApi, CreatedUtc = now, UpdatedUtc = now }; @@ -112,7 +129,7 @@ public List GetAllForms() try { using var scope = scopeProvider.CreateScope(autoComplete: true); - scope.Database.Execute("DELETE FROM utpro_SimpleFormSubmission WHERE FormId = @0", id); + scope.Database.Execute("DELETE FROM utpro_SimpleFormEntry WHERE FormId = @0", id); scope.Database.Execute("DELETE FROM utpro_SimpleForm WHERE Id = @0", id); return (true, "Deleted"); } @@ -132,27 +149,42 @@ public List GetAllForms() if (form == null) return (false, "Form not found"); if (!form.IsEnabled) return (false, "Form is disabled"); - // Validate required fields var fields = string.IsNullOrEmpty(form.FieldsJson) ? [] : JsonSerializer.Deserialize>(form.FieldsJson, JsonOpts) ?? []; - foreach (var f in fields.Where(f => f.Required)) + foreach (var f in fields.Where(f => f.Required && !f.IsHidden)) { if (!data.TryGetValue(f.Name, out var val) || string.IsNullOrWhiteSpace(val)) return (false, $"Field '{f.Label}' is required"); } - if (form.StoreSubmissions) + // Encrypt sensitive fields + var sensitiveNames = fields + .Where(f => f.IsSensitive || f.Type == "password") + .Select(f => f.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var storageData = new Dictionary(data); + foreach (var key in storageData.Keys.Where(k => sensitiveNames.Contains(k)).ToList()) + { + var raw = storageData[key]; + if (!string.IsNullOrEmpty(raw)) + { + storageData[key] = EncryptedPrefix + Protector.Protect(raw); + } + } + + if (form.StoreEntries) { - var sub = new SimpleFormSubmissionDto + var entry = new SimpleFormEntryDto { FormId = form.Id, - DataJson = JsonSerializer.Serialize(data, JsonOpts), + DataJson = JsonSerializer.Serialize(storageData, JsonOpts), IpAddress = ip, UserAgent = ua?.Length > 500 ? ua[..500] : ua, CreatedUtc = DateTime.UtcNow }; - scope.Database.Insert(sub); + scope.Database.Insert(entry); } return (true, form.SuccessMessage ?? "Thank you for your submission!"); @@ -164,34 +196,42 @@ public List GetAllForms() } } - public PagedResult GetSubmissions(int formId, int skip, int take) + public PagedResult GetEntries(int formId, int skip, int take, bool canViewSensitive = false, string? search = null, DateTime? dateFrom = null, DateTime? dateTo = null) { using var scope = scopeProvider.CreateScope(autoComplete: true); var db = scope.Database; var sql = scope.SqlContext.Sql() - .Select("*").From("utpro_SimpleFormSubmission") - .Where("FormId = @0", formId) - .OrderByDescending("CreatedUtc"); + .Select("*").From("utpro_SimpleFormEntry") + .Where("FormId = @0", formId); + + if (dateFrom.HasValue) + sql = sql.Where("CreatedUtc >= @0", dateFrom.Value.Date); + if (dateTo.HasValue) + sql = sql.Where("CreatedUtc < @0", dateTo.Value.Date.AddDays(1)); + if (!string.IsNullOrWhiteSpace(search)) + sql = sql.Where("(DataJson LIKE @0 OR IpAddress LIKE @0)", $"%{search}%"); + + sql = sql.OrderByDescending("CreatedUtc"); - var page = db.Page(skip / Math.Max(take, 1) + 1, take, sql); - return new PagedResult + var page = db.Page(skip / Math.Max(take, 1) + 1, take, sql); + return new PagedResult { - Items = page.Items.Select(MapSubmission), + Items = page.Items.Select(s => MapEntry(s, canViewSensitive)), Total = page.TotalItems }; } - public (bool Success, string Message) DeleteSubmission(int id) + public (bool Success, string Message) DeleteEntry(int id) { try { using var scope = scopeProvider.CreateScope(autoComplete: true); - scope.Database.Execute("DELETE FROM utpro_SimpleFormSubmission WHERE Id = @0", id); + scope.Database.Execute("DELETE FROM utpro_SimpleFormEntry WHERE Id = @0", id); return (true, "Deleted"); } catch (Exception ex) { - logger.LogError(ex, "Error deleting submission {Id}", id); + logger.LogError(ex, "Error deleting entry {Id}", id); return (false, ex.Message); } } @@ -203,15 +243,39 @@ public PagedResult GetSubmissions(int formId, int skip, int ? [] : JsonSerializer.Deserialize>(dto.FieldsJson, JsonOpts) ?? [], SuccessMessage = dto.SuccessMessage, RedirectUrl = dto.RedirectUrl, EmailTo = dto.EmailTo, EmailSubject = dto.EmailSubject, - StoreSubmissions = dto.StoreSubmissions, IsEnabled = dto.IsEnabled, + StoreEntries = dto.StoreEntries, IsEnabled = dto.IsEnabled, + VisibleColumns = string.IsNullOrEmpty(dto.VisibleColumnsJson) + ? null : JsonSerializer.Deserialize>(dto.VisibleColumnsJson, JsonOpts), + EnableRenderApi = dto.EnableRenderApi, + EnableEntriesApi = dto.EnableEntriesApi, CreatedUtc = dto.CreatedUtc, UpdatedUtc = dto.UpdatedUtc }; - private static SubmissionViewModel MapSubmission(SimpleFormSubmissionDto dto) => new() + private EntryViewModel MapEntry(SimpleFormEntryDto dto, bool canViewSensitive = false) { - Id = dto.Id, FormId = dto.FormId, - Data = string.IsNullOrEmpty(dto.DataJson) - ? [] : JsonSerializer.Deserialize>(dto.DataJson, JsonOpts) ?? [], - IpAddress = dto.IpAddress, CreatedUtc = dto.CreatedUtc - }; + var data = string.IsNullOrEmpty(dto.DataJson) + ? new Dictionary() + : JsonSerializer.Deserialize>(dto.DataJson, JsonOpts) ?? []; + + foreach (var key in data.Keys.ToList()) + { + var val = data[key]; + if (val != null && val.StartsWith(EncryptedPrefix)) + { + if (canViewSensitive) + { + try { data[key] = Protector.Unprotect(val[EncryptedPrefix.Length..]); } + catch { data[key] = "[decryption error]"; } + } + else { data[key] = MaskedValue; } + } + } + + return new EntryViewModel + { + Id = dto.Id, FormId = dto.FormId, + Data = data, + IpAddress = dto.IpAddress, CreatedUtc = dto.CreatedUtc + }; + } } diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs index effdf60..7e89364 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs @@ -1,11 +1,25 @@ +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using uTPro.Feature.SimpleForm.Services; namespace uTPro.Feature.SimpleForm.ViewComponents; -public class SimpleFormViewComponent(ISimpleFormService formService) : ViewComponent +public class SimpleFormViewComponent(ISimpleFormService formService, IWebHostEnvironment env) : ViewComponent { - public IViewComponentResult Invoke(string alias, string? cssClass = null, string? submitBtnText = null) + /// + /// Renders a SimpleForm by alias. + /// Template resolution order: + /// 1. Explicit template parameter: ~/Views/Partials/SimpleForm/{template}.cshtml + /// 2. Form-specific template: ~/Views/Partials/SimpleForm/{alias}.cshtml + /// 3. Default template: ~/Views/Partials/SimpleForm/Default.cshtml + /// + public IViewComponentResult Invoke( + string alias, + string? template = null, + string? cssClass = null, + string? submitBtnText = null, + bool? showReset = null, + string? resetBtnText = null) { var form = formService.GetFormByAlias(alias); if (form == null || !form.IsEnabled) @@ -13,6 +27,35 @@ public IViewComponentResult Invoke(string alias, string? cssClass = null, string ViewBag.FormCssClass = cssClass ?? ""; ViewBag.SubmitBtnText = submitBtnText ?? "Submit"; - return View("~/Views/Partials/SimpleForm/Default.cshtml", form); + ViewBag.ShowReset = showReset; + ViewBag.ResetBtnText = resetBtnText; + + var viewPath = ResolveTemplate(template, alias); + return View(viewPath, form); + } + + private string ResolveTemplate(string? template, string alias) + { + // 1. Explicit template + if (!string.IsNullOrEmpty(template)) + { + var explicitPath = $"~/Views/Partials/SimpleForm/{template}.cshtml"; + if (ViewExists(explicitPath)) return explicitPath; + } + + // 2. Form-specific template (by alias) + var aliasPath = $"~/Views/Partials/SimpleForm/{alias}.cshtml"; + if (ViewExists(aliasPath)) return aliasPath; + + // 3. Default + return "~/Views/Partials/SimpleForm/Default.cshtml"; + } + + private bool ViewExists(string viewPath) + { + var physicalPath = Path.Combine( + env.ContentRootPath, + viewPath.Replace("~/", "").Replace("/", Path.DirectorySeparatorChar.ToString())); + return System.IO.File.Exists(physicalPath); } } diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj b/uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj index 832a792..71c2ef1 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj @@ -6,6 +6,10 @@ enable + + + + diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/api.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/api.js new file mode 100644 index 0000000..77fe91e --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/api.js @@ -0,0 +1,29 @@ +// ── API helper & constants ── +export const API = '/umbraco/management/api/v1/utpro/simple-form'; + +/** + * Make an authenticated POST request to the backoffice API. + * @param {string} url + * @param {object} body + * @param {object} authContext - UMB_AUTH_CONTEXT instance + * @returns {Promise} + */ +export async function apiPost(url, body = {}, authContext = null) { + const config = authContext?.getOpenApiConfiguration(); + const headers = { 'Content-Type': 'application/json' }; + if (config?.token) { + const t = await config.token(); + if (t) headers['Authorization'] = 'Bearer ' + t; + } + const resp = await fetch(url, { + method: 'POST', + headers, + credentials: config?.credentials || 'same-origin', + body: JSON.stringify(body) + }); + if (!resp.ok) { + const e = await resp.json().catch(() => ({})); + throw new Error(e.message || 'Failed'); + } + return resp.json(); +} diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/index.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/index.js index c9d00f8..aada3ba 100644 --- a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/index.js +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/index.js @@ -1,26 +1,55 @@ +// ── Entry point: Simple Form Dashboard ── +// Views and styles are split into separate files for maintainability. +// api.js – API helper & constants +// styles.js – All CSS styles +// views/list-view.js – Form list +// views/editor-view.js – Form editor +// views/entries-view.js – Entries table +// views/detail-view.js – Entry detail overlay + import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { html, css, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; -const API = '/umbraco/management/api/v1/utpro/simple-form'; +import { API, apiPost } from './api.js'; +import { dashboardStyles } from './styles.js'; +import { renderList } from './views/list-view.js'; +import { renderEditor } from './views/editor-view.js'; +import { renderEntries } from './views/entries-view.js'; +import { renderDetail } from './views/detail-view.js'; export class UtproSimpleFormDashboard extends UmbLitElement { + + // ── Reactive properties ── static properties = { _view: { type: String, state: true }, _forms: { type: Array, state: true }, _loading: { type: Boolean, state: true }, _editForm: { type: Object, state: true }, _fieldTypes: { type: Array, state: true }, - _submissions: { type: Array, state: true }, - _subTotal: { type: Number, state: true }, - _subSkip: { type: Number, state: true }, + _entries: { type: Array, state: true }, + _entryTotal: { type: Number, state: true }, + _entrySkip: { type: Number, state: true }, _viewFormId: { type: Number, state: true }, _error: { type: String, state: true }, _success: { type: String, state: true }, - _selectedSubs: { type: Array, state: true }, - _detailSub: { type: Object, state: true }, + _selectedEntries: { type: Array, state: true }, + _detailEntry: { type: Object, state: true }, + _permissions: { type: Object, state: true }, + _search: { type: String, state: true }, + _dateFrom: { type: String, state: true }, + _dateTo: { type: String, state: true }, + _showColumnSettings: { type: Boolean, state: true }, + _entryCount: { type: Number, state: true }, + _typePickerIdx: { type: Number, state: true }, + _typePickerSearch: { type: String, state: true }, }; + + // ── Styles ── + static styles = dashboardStyles; + #authContext; + constructor() { super(); this._view = 'list'; @@ -28,66 +57,89 @@ export class UtproSimpleFormDashboard extends UmbLitElement { this._loading = false; this._editForm = null; this._fieldTypes = []; - this._submissions = []; - this._subTotal = 0; - this._subSkip = 0; + this._entries = []; + this._entryTotal = 0; + this._entrySkip = 0; this._viewFormId = 0; this._error = ''; this._success = ''; - this._selectedSubs = []; - this._detailSub = null; + this._selectedEntries = []; + this._detailEntry = null; + this._permissions = { isAdmin: false, canViewSensitive: false }; + this._search = ''; + this._dateFrom = ''; + this._dateTo = ''; + this._showColumnSettings = false; + this._entryCount = 0; + this._typePickerIdx = -1; + this._typePickerSearch = ''; this.consumeContext(UMB_AUTH_CONTEXT, (ctx) => { this.#authContext = ctx; }); } + async connectedCallback() { super.connectedCallback(); + await this._loadPermissions(); await this._loadForms(); await this._loadFieldTypes(); } - async _getHeaders() { - const config = this.#authContext?.getOpenApiConfiguration(); - const h = {}; - if (config?.token) { const t = await config.token(); if (t) h['Authorization'] = 'Bearer ' + t; } - return { headers: h, credentials: config?.credentials || 'same-origin' }; - } + + // ── API helper ── async _api(url, body = {}) { - const auth = await this._getHeaders(); - const resp = await fetch(url, { - method: 'POST', headers: { ...auth.headers, 'Content-Type': 'application/json' }, - credentials: auth.credentials, body: JSON.stringify(body) - }); - if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.message || 'Failed'); } - return resp.json(); + return apiPost(url, body, this.#authContext); } + _msg(m, err = false) { - if (err) { this._error = m; this._success = ''; } else { this._success = m; this._error = ''; } + if (err) { this._error = m; this._success = ''; } + else { this._success = m; this._error = ''; } setTimeout(() => { this._error = ''; this._success = ''; }, 3000); } + + // ── Permissions ── + async _loadPermissions() { + try { + this._permissions = await this._api(API + '/permissions'); + } catch { + this._permissions = { isAdmin: false, canViewSensitive: false }; + } + } + + // ── Data loading ── async _loadForms() { this._loading = true; - try { this._forms = await this._api(API + '/list'); } catch (e) { this._msg(e.message, true); } + try { this._forms = await this._api(API + '/list'); } + catch (e) { this._msg(e.message, true); } this._loading = false; } + async _loadFieldTypes() { try { this._fieldTypes = await this._api(API + '/field-types'); } catch {} } + // ── Form CRUD ── _newForm() { this._editForm = { id: 0, name: '', alias: '', fields: [], successMessage: 'Thank you!', redirectUrl: '', emailTo: '', emailSubject: '', - storeSubmissions: true, isEnabled: true + storeEntries: true, isEnabled: true }; this._view = 'edit'; } + async _editExisting(id) { try { - const form = await this._api(API + '/get', { id }); - this._editForm = form; + this._editForm = await this._api(API + '/get', { id }); + this._showColumnSettings = false; + const res = await this._api(API + '/entries', { formId: id, skip: 0, take: 1 }); + this._entryCount = res.total || 0; this._view = 'edit'; } catch (e) { this._msg(e.message, true); } } + async _saveForm() { - if (!this._editForm.name || !this._editForm.alias) { this._msg('Name and Alias required', true); return; } + if (!this._editForm.name || !this._editForm.alias) { + this._msg('Name and Alias required', true); + return; + } try { const res = await this._api(API + '/save', this._editForm); this._msg(res.message); @@ -95,8 +147,9 @@ export class UtproSimpleFormDashboard extends UmbLitElement { await this._loadForms(); } catch (e) { this._msg(e.message, true); } } + async _deleteForm(id) { - if (!confirm('Delete this form and all submissions?')) return; + if (!confirm('Delete this form and all entries?')) return; try { await this._api(API + '/delete', { id }); this._msg('Deleted'); @@ -104,6 +157,8 @@ export class UtproSimpleFormDashboard extends UmbLitElement { if (this._editForm?.id === id) { this._editForm = null; this._view = 'list'; } } catch (e) { this._msg(e.message, true); } } + + // ── Field management ── _addField() { const f = this._editForm; const idx = f.fields.length; @@ -116,10 +171,16 @@ export class UtproSimpleFormDashboard extends UmbLitElement { }]; this.requestUpdate(); } + _removeField(idx) { + const removedName = this._editForm.fields[idx]?.name; this._editForm.fields = this._editForm.fields.filter((_, i) => i !== idx); + if (removedName && this._editForm.visibleColumns) { + this._editForm.visibleColumns = this._editForm.visibleColumns.filter(c => c !== removedName); + } this.requestUpdate(); } + _moveField(idx, dir) { const arr = [...this._editForm.fields]; const newIdx = idx + dir; @@ -129,60 +190,92 @@ export class UtproSimpleFormDashboard extends UmbLitElement { this._editForm.fields = arr; this.requestUpdate(); } + _updateField(idx, key, val) { this._editForm.fields[idx][key] = val; + if (key === 'type' && val === 'password') { + this._editForm.fields[idx].isSensitive = true; + } this.requestUpdate(); } + _addOption(idx) { if (!this._editForm.fields[idx].options) this._editForm.fields[idx].options = []; this._editForm.fields[idx].options.push({ text: '', value: '' }); this.requestUpdate(); } + _removeOption(fIdx, oIdx) { this._editForm.fields[fIdx].options.splice(oIdx, 1); this.requestUpdate(); } - async _viewSubmissions(formId) { + + // ── Entries ── + async _viewEntries(formId) { this._viewFormId = formId; - this._subSkip = 0; - this._view = 'submissions'; - await this._loadSubmissions(); + this._entrySkip = 0; + this._search = ''; + this._dateFrom = ''; + this._dateTo = ''; + this._selectedEntries = []; + this._view = 'entries'; + await this._loadEntries(); } - async _loadSubmissions() { + + async _loadEntries() { try { - const res = await this._api(API + '/submissions', { formId: this._viewFormId, skip: this._subSkip, take: 20 }); - this._submissions = res.items || []; - this._subTotal = res.total || 0; + const body = { + formId: this._viewFormId, skip: this._entrySkip, take: 20 + }; + if (this._search) body.search = this._search; + if (this._dateFrom) body.dateFrom = this._dateFrom; + if (this._dateTo) body.dateTo = this._dateTo; + const res = await this._api(API + '/entries', body); + this._entries = res.items || []; + this._entryTotal = res.total || 0; } catch (e) { this._msg(e.message, true); } } - async _deleteSubmission(id) { - if (!confirm('Delete this submission?')) return; - try { await this._api(API + '/delete-submission', { id }); this._msg('Deleted'); this._selectedSubs = this._selectedSubs.filter(x => x !== id); await this._loadSubmissions(); } - catch (e) { this._msg(e.message, true); } + + async _deleteEntry(id) { + if (!confirm('Delete this entry?')) return; + try { + await this._api(API + '/delete-entry', { id }); + this._msg('Deleted'); + this._selectedEntries = this._selectedEntries.filter(x => x !== id); + await this._loadEntries(); + } catch (e) { this._msg(e.message, true); } } - _toggleSubSelect(id) { - if (this._selectedSubs.includes(id)) this._selectedSubs = this._selectedSubs.filter(x => x !== id); - else this._selectedSubs = [...this._selectedSubs, id]; + + _toggleEntrySelect(id) { + if (this._selectedEntries.includes(id)) + this._selectedEntries = this._selectedEntries.filter(x => x !== id); + else + this._selectedEntries = [...this._selectedEntries, id]; } + _toggleSelectAll() { - if (this._selectedSubs.length === this._submissions.length) this._selectedSubs = []; - else this._selectedSubs = this._submissions.map(s => s.id); + if (this._selectedEntries.length === this._entries.length) + this._selectedEntries = []; + else + this._selectedEntries = this._entries.map(s => s.id); } + async _bulkDelete() { - if (!this._selectedSubs.length) return; - if (!confirm('Delete ' + this._selectedSubs.length + ' submissions?')) return; - for (const id of this._selectedSubs) { - try { await this._api(API + '/delete-submission', { id }); } catch {} + if (!this._selectedEntries.length) return; + if (!confirm('Delete ' + this._selectedEntries.length + ' entries?')) return; + for (const id of this._selectedEntries) { + try { await this._api(API + '/delete-entry', { id }); } catch {} } - this._selectedSubs = []; + this._selectedEntries = []; this._msg('Deleted'); - await this._loadSubmissions(); + await this._loadEntries(); } + _exportCsv() { - if (!this._submissions.length) return; - const allKeys = [...new Set(this._submissions.flatMap(s => Object.keys(s.data || {})))]; + if (!this._entries.length) return; + const allKeys = [...new Set(this._entries.flatMap(s => Object.keys(s.data || {})))]; const headers = ['Date', 'IP', ...allKeys]; - const rows = this._submissions.map(s => { + const rows = this._entries.map(s => { const date = new Date(s.createdUtc).toLocaleString(); const ip = s.ipAddress || ''; const fields = allKeys.map(k => '"' + (s.data?.[k] || '').replace(/"/g, '""') + '"'); @@ -193,307 +286,23 @@ export class UtproSimpleFormDashboard extends UmbLitElement { const url = URL.createObjectURL(blob); const a = document.createElement('a'); const formName = this._forms.find(f => f.id === this._viewFormId)?.alias || 'form'; - a.href = url; a.download = formName + '-submissions.csv'; a.click(); + a.href = url; a.download = formName + '-entries.csv'; a.click(); URL.revokeObjectURL(url); } - _viewDetail(sub) { this._detailSub = sub; } - _closeDetail() { this._detailSub = null; } + _viewDetail(entry) { this._detailEntry = entry; } + _closeDetail() { this._detailEntry = null; } + + // ── Render ── render() { return html` ${this._error ? html`
${this._error}
` : nothing} ${this._success ? html`
${this._success}
` : nothing} - ${this._view === 'list' ? this._renderList() - : this._view === 'edit' ? this._renderEditor() - : this._renderSubmissions()} - ${this._detailSub ? this._renderDetail() : nothing}`; - } - _renderList() { - return html` - -
-

Form Builder

- + New Form -
- ${this._loading ? html`
` : nothing} - ${!this._forms.length && !this._loading ? html`
No forms yet. Create one!
` : nothing} - ${this._forms.length ? html` - - - Name - Alias - Fields - Status - Actions - - ${this._forms.map(f => html` - - this._editExisting(f.id)}>${f.name} - ${f.alias} - ${f.fields?.length || 0} - ${f.isEnabled ? html`Active` : html`Disabled`} - - this._editExisting(f.id)}>Edit - this._viewSubmissions(f.id)}>Submissions - this._deleteForm(f.id)}>Delete - - `)} - ` : nothing} -
`; - } - - _renderEditor() { - const f = this._editForm; - if (!f) return nothing; - const needsOptions = (t) => ['select','radio','checkbox'].includes(t); - return html` - -
- { this._view = 'list'; }}>← Back -

${f.id ? 'Edit' : 'New'} Form

- this._saveForm()}>Save Form -
-
- - - - - - - - -
-
-

Fields

- this._addField()}>+ Add Field -
- ${f.fields.map((field, idx) => html` -
-
- #${idx + 1} - -
- this._moveField(idx, -1)} ?disabled=${idx === 0}>▲ - this._moveField(idx, 1)} ?disabled=${idx === f.fields.length - 1}>▼ - this._removeField(idx)}>✕ -
-
-
- - - - - - - - - -
- ${needsOptions(field.type) ? html` -
-
Options - this._addOption(idx)}>+ Option -
- ${(field.options || []).map((opt, oIdx) => html` -
- { opt.text = e.target.value; this.requestUpdate(); }}> - { opt.value = e.target.value; this.requestUpdate(); }}> - this._removeOption(idx, oIdx)}>✕ -
`)} -
` : nothing} -
`)} - ${f.id ? html`
-

Embed Code

- POST /api/utpro/simple-form/submit { "alias": "${f.alias}", "data": { ... } }
- GET /api/utpro/simple-form/render/${f.alias} -
` : nothing} -
`; - } - - _renderSubmissions() { - const form = this._forms.find(f => f.id === this._viewFormId); - const formName = form?.name || 'Form'; - const pages = Math.max(1, Math.ceil(this._subTotal / 20)); - const page = Math.floor(this._subSkip / 20) + 1; - const allKeys = [...new Set(this._submissions.flatMap(s => Object.keys(s.data || {})))]; - const allSelected = this._submissions.length > 0 && this._selectedSubs.length === this._submissions.length; - return html` - -
- { this._view = 'list'; this._selectedSubs = []; }}>← Back -

Submissions: ${formName}

-
- ${this._subTotal} entries - ${this._submissions.length ? html` - Export CSV - ` : nothing} - ${this._selectedSubs.length ? html` - - Delete (${this._selectedSubs.length}) - - ` : nothing} -
-
- ${!this._submissions.length ? html`
No submissions yet
` : html` - - - - - - Date - IP - ${allKeys.map(k => html`${k}`)} - Actions - - ${this._submissions.map(s => html` - - - this._toggleSubSelect(s.id)} /> - - ${new Date(s.createdUtc).toLocaleString()} - ${s.ipAddress || ''} - ${allKeys.map(k => html`${s.data?.[k] || ''}`)} - - this._viewDetail(s)} title="View">☰ - this._deleteSubmission(s.id)} title="Delete">✕ - - `)} - - ${this._subTotal > 20 ? html` - ` : nothing} - `} -
`; - } - - _renderDetail() { - const s = this._detailSub; - if (!s) return nothing; - const entries = Object.entries(s.data || {}); - return html` -
{ if (e.target === e.currentTarget) this._closeDetail(); }}> -
-
-

Submission #${s.id}

- ✕ Close -
-
-
- Date - ${new Date(s.createdUtc).toLocaleString()} -
-
- IP Address - ${s.ipAddress || 'N/A'} -
- ${entries.map(([k, v]) => html` -
- ${k} - ${v || ''} -
- `)} -
- -
-
`; + ${this._view === 'list' ? renderList(this) + : this._view === 'edit' ? renderEditor(this) + : renderEntries(this)} + ${this._detailEntry ? renderDetail(this) : nothing}`; } - - static styles = css` - :host { display: block; padding: 20px; } - .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; } - .toolbar h2 { margin: 0; font-size: 1.3rem; } - .msg { padding: 8px 14px; border-radius: 4px; margin-bottom: 10px; font-size: 0.9rem; } - .error { background: #fde8e8; color: #c0392b; } - .success { background: #e8fde8; color: #27ae60; } - .loading { display: flex; justify-content: center; padding: 40px; } - .empty { text-align: center; padding: 40px; color: #888; font-style: italic; } - .link { color: var(--uui-color-interactive, #1b264f); cursor: pointer; font-weight: 500; text-decoration: none; } - .link:hover { text-decoration: underline; } - .badge { padding: 2px 8px; border-radius: 10px; font-size: 0.8rem; font-weight: 500; } - .badge.on { background: #e8fde8; color: #27ae60; } - .badge.off { background: #fde8e8; color: #c0392b; } - .action-cell { display: flex; gap: 4px; } - code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 0.85rem; } - uui-table { width: 100%; } - .form-grid { - display: grid; grid-template-columns: 1fr 1fr; gap: 12px; - margin-bottom: 20px; padding: 16px; - background: var(--uui-color-surface-alt, #f9f9f9); border-radius: 6px; - } - .form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; font-weight: 500; } - .check-label { flex-direction: row !important; align-items: center; gap: 8px !important; } - .section-header { display: flex; justify-content: space-between; align-items: center; margin: 16px 0 8px; } - .section-header h3 { margin: 0; } - .field-card { - border: 1px solid var(--uui-color-border, #ddd); border-radius: 6px; - margin-bottom: 10px; overflow: hidden; - } - .field-header { - display: flex; align-items: center; gap: 10px; padding: 10px 14px; - background: var(--uui-color-surface-alt, #f4f4f4); - } - .field-num { font-weight: 600; color: #888; min-width: 30px; } - .field-header select { - padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; - font-size: 0.9rem; background: #fff; - } - .field-actions { margin-left: auto; display: flex; gap: 4px; } - .field-body { - display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 14px; - } - .field-body label { display: flex; flex-direction: column; gap: 4px; font-size: 0.8rem; font-weight: 500; } - .options-section { padding: 0 14px 14px; } - .option-row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; } - .option-row uui-input { flex: 1; } - .embed-info { - margin-top: 20px; padding: 14px; background: #f0f4ff; border-radius: 6px; - border: 1px solid #c8d6f0; - } - .embed-info h4 { margin: 0 0 8px; } - .embed-info code { display: block; margin: 4px 0; padding: 6px 10px; background: #fff; } - .pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; } - .page-info { color: #888; font-size: 0.9rem; } - .toolbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; } - .cell-truncate { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .row-selected { background: var(--uui-color-surface-alt, #f0f4ff) !important; } - .overlay { - position: fixed; top: 0; left: 0; right: 0; bottom: 0; - background: rgba(0,0,0,0.5); z-index: 9999; - display: flex; justify-content: center; align-items: center; - } - .detail-panel { - background: var(--uui-color-surface, #fff); border-radius: 8px; - width: 600px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column; - box-shadow: 0 8px 32px rgba(0,0,0,0.3); - } - .detail-header { - display: flex; justify-content: space-between; align-items: center; - padding: 16px 20px; border-bottom: 1px solid #e0e0e0; - } - .detail-header h3 { margin: 0; } - .detail-body { padding: 20px; overflow-y: auto; flex: 1; } - .detail-row { - display: flex; padding: 10px 0; border-bottom: 1px solid #f0f0f0; - } - .detail-label { font-weight: 600; min-width: 120px; color: #555; font-size: 0.9rem; } - .detail-value { flex: 1; word-break: break-word; white-space: pre-wrap; } - .detail-footer { padding: 12px 20px; border-top: 1px solid #e0e0e0; display: flex; justify-content: flex-end; } - `; } customElements.define('utpro-simple-form-dashboard', UtproSimpleFormDashboard); diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/styles.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/styles.js new file mode 100644 index 0000000..8c50c36 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/styles.js @@ -0,0 +1,205 @@ +import { css } from '@umbraco-cms/backoffice/external/lit'; + +export const dashboardStyles = css` + :host { display: block; padding: 20px; } + + /* ── Toolbar ── */ + .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; } + .toolbar h2 { margin: 0; font-size: 1.3rem; } + .toolbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; } + + /* ── Messages ── */ + .msg { padding: 8px 14px; border-radius: 4px; margin-bottom: 10px; font-size: 0.9rem; } + .error { background: #fde8e8; color: #c0392b; } + .success { background: #e8fde8; color: #27ae60; } + + /* ── States ── */ + .loading { display: flex; justify-content: center; padding: 40px; } + .empty { text-align: center; padding: 40px; color: #888; font-style: italic; } + + /* ── List view ── */ + .link { color: var(--uui-color-interactive, #1b264f); cursor: pointer; font-weight: 500; text-decoration: none; } + .link:hover { text-decoration: underline; } + .badge { padding: 2px 8px; border-radius: 10px; font-size: 0.8rem; font-weight: 500; } + .badge.on { background: #e8fde8; color: #27ae60; } + .badge.off { background: #fde8e8; color: #c0392b; } + .action-cell { display: flex; gap: 4px; } + code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 0.85rem; } + uui-table { width: 100%; } + + /* ── Form editor grid ── */ + .form-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 12px; + margin-bottom: 20px; padding: 16px; + background: var(--uui-color-surface-alt, #f9f9f9); border-radius: 6px; + } + .form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; font-weight: 500; } + .check-label { flex-direction: row !important; align-items: center; gap: 8px !important; } + + /* ── Section headers ── */ + .section-header { display: flex; justify-content: space-between; align-items: center; margin: 16px 0 8px; } + .section-header h3 { margin: 0; } + + /* ── Field cards ── */ + .field-card { + border: 1px solid var(--uui-color-border, #ddd); border-radius: 6px; + overflow: hidden; margin-bottom: 10px; + } + .field-card.field-hidden { + opacity: 0.5; border-style: dashed; + } + .field-header { + display: flex; align-items: center; gap: 8px; padding: 10px 14px; + background: var(--uui-color-surface-alt, #f4f4f4); flex-wrap: wrap; + } + .field-num { font-weight: 600; color: #888; min-width: 30px; } + .field-header select, .field-body select { + padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; + font-size: 0.9rem; background: #fff; height: 32px; box-sizing: border-box; + } + .field-actions { margin-left: auto; display: flex; gap: 4px; } + + /* ── Type picker button ── */ + .type-picker-btn { + padding: 4px 12px; border: 1px solid #ccc; border-radius: 4px; + font-size: 0.9rem; background: #fff; height: 32px; box-sizing: border-box; + cursor: pointer; display: flex; align-items: center; gap: 6px; + transition: border-color 0.2s; + } + .type-picker-btn:hover { border-color: #888; } + + /* ── Type picker dialog ── */ + .type-picker-dialog { + background: var(--uui-color-surface, #fff); border-radius: 8px; + width: 400px; max-width: 90vw; height: 480px; display: flex; flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + } + .type-picker-header { + display: flex; justify-content: space-between; align-items: center; + padding: 14px 18px; border-bottom: 1px solid #e0e0e0; + } + .type-picker-header h3 { margin: 0; font-size: 1rem; } + .type-picker-search { padding: 12px 18px; border-bottom: 1px solid #f0f0f0; } + .type-picker-search uui-input { width: 100%; } + .type-picker-list { overflow-y: auto; flex: 1; padding: 6px 0; } + .type-picker-option { + display: flex; align-items: center; justify-content: space-between; + width: 100%; padding: 7px 18px; border: none; background: none; + cursor: pointer; font-size: 0.85rem; text-align: left; + transition: background 0.1s; + } + .type-picker-option:hover { background: var(--uui-color-surface-alt, #f4f4f4); } + .type-picker-option.active { + background: var(--uui-color-surface-alt, #f3f3f5); + font-weight: 600; + border-left: 3px solid var(--uui-color-interactive, #1b264f); + } + .type-picker-label { flex: 1; } + .type-picker-type { color: #999; font-size: 0.8rem; font-family: monospace; } + .type-picker-empty { padding: 20px; text-align: center; color: #888; font-style: italic; } + .field-body { + display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 14px; + } + .field-body label { display: flex; flex-direction: column; gap: 4px; font-size: 0.8rem; font-weight: 500; min-width: 0; overflow: hidden; } + .field-body uui-input { width: 100%; min-width: 0; } + .field-body select { width: 100%; } + + /* ── Options (select/radio/checkbox) ── */ + .options-section { padding: 0 14px 14px; } + + /* ── Type-specific attributes ── */ + .field-attrs { + display: flex; gap: 10px; padding: 8px 14px; + background: var(--uui-color-surface-alt, #fafafa); + border-top: 1px solid #eee; flex-wrap: wrap; + } + .field-attrs label { + display: flex; flex-direction: column; gap: 4px; + font-size: 0.8rem; font-weight: 500; flex: 1; min-width: 120px; + } + .field-attrs uui-input { width: 100%; } + .div-content-label { grid-column: 1 / -1; } + .div-content-editor { + width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px; + font-family: monospace; font-size: 0.85rem; resize: vertical; + background: #fff; box-sizing: border-box; + } + + /* ── Settings panel ── */ + .settings-panel { + margin-bottom: 16px; padding: 16px; + background: var(--uui-color-surface-alt, #f0f4ff); + border: 1px solid #c8d6f0; border-radius: 6px; + } + .settings-header { margin-bottom: 12px; } + .settings-header h3 { margin: 0 0 4px; font-size: 1rem; } + .settings-hint { font-size: 0.8rem; color: #888; } + .settings-body { + display: flex; flex-wrap: wrap; gap: 10px 20px; + } + .settings-col-item { + min-width: 140px; padding: 4px 8px; + background: #fff; border: 1px solid #e0e0e0; border-radius: 4px; + cursor: grab; user-select: none; transition: box-shadow 0.15s, opacity 0.15s; + } + .settings-col-item:active { cursor: grabbing; } + .settings-col-item.dragging { opacity: 0.4; } + .settings-col-item.drag-over { box-shadow: inset 0 0 0 2px var(--uui-color-interactive, #1b264f); } + .drag-handle { color: #aaa; margin-right: 4px; font-size: 0.8rem; } + .option-row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; } + .option-row uui-input { flex: 1; } + + /* ── Embed info ── */ + .embed-render-row { + display: flex; align-items: center; gap: 12px; margin: 6px 0; + } + .embed-render-row code { + flex: 1; display: inline-block; padding: 6px 10px; + background: #fff; border: 1px solid #e0e0e0; border-radius: 4px; + font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + + /* ── Entries ── */ + .filter-bar { + display: flex; align-items: center; gap: 12px; margin-bottom: 16px; + padding: 10px 14px; background: var(--uui-color-surface-alt, #f9f9f9); + border-radius: 6px; flex-wrap: wrap; + } + .filter-search { flex: 1; min-width: 200px; } + .filter-dates { display: flex; align-items: center; gap: 10px; margin-left: auto; } + .filter-date-label { + display: flex; align-items: center; gap: 4px; font-size: 0.85rem; font-weight: 500; + } + .filter-date-label input[type="date"] { + padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; + font-size: 0.85rem; background: #fff; height: 32px; box-sizing: border-box; + } + .pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; } + .page-info { color: #888; font-size: 0.9rem; } + .cell-truncate { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .row-selected { background: var(--uui-color-surface-alt, #f0f4ff) !important; } + + /* ── Detail overlay ── */ + .overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); z-index: 9999; + display: flex; justify-content: center; align-items: center; + } + .detail-panel { + background: var(--uui-color-surface, #fff); border-radius: 8px; + width: 600px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + } + .detail-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid #e0e0e0; + } + .detail-header h3 { margin: 0; } + .detail-body { padding: 20px; overflow-y: auto; flex: 1; } + .detail-row { + display: flex; padding: 10px 0; border-bottom: 1px solid #f0f0f0; + } + .detail-label { font-weight: 600; min-width: 120px; color: #555; font-size: 0.9rem; } + .detail-value { flex: 1; word-break: break-word; white-space: pre-wrap; } + .detail-footer { padding: 12px 20px; border-top: 1px solid #e0e0e0; display: flex; justify-content: flex-end; } +`; diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/detail-view.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/detail-view.js new file mode 100644 index 0000000..78c5d5d --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/detail-view.js @@ -0,0 +1,44 @@ +import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; + +/** + * Renders the entry detail overlay. + * @param {object} host - the dashboard element + */ +export function renderDetail(host) { + const s = host._detailEntry; + if (!s) return nothing; + + const isAdmin = host._permissions?.isAdmin; + const entries = Object.entries(s.data || {}); + + return html` +
{ if (e.target === e.currentTarget) host._closeDetail(); }}> +
+
+

Entry #${s.id}

+ host._closeDetail()}>✕ Close +
+
+
+ Date + ${new Date(s.createdUtc).toLocaleString()} +
+
+ IP Address + ${s.ipAddress || 'N/A'} +
+ ${entries.map(([k, v]) => html` +
+ ${k} + ${v || ''} +
+ `)} +
+ ${isAdmin ? html` + + ` : nothing} +
+
`; +} diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/editor-view.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/editor-view.js new file mode 100644 index 0000000..94935b1 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/editor-view.js @@ -0,0 +1,341 @@ +import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; + +/** + * Renders the form editor view. + * @param {object} host - the dashboard element + */ +export function renderEditor(host) { + const f = host._editForm; + if (!f) return nothing; + + const needsOptions = (t) => ['select', 'radio', 'checkbox'].includes(t); + const showSettings = host._showColumnSettings; + + return html` + +
+ { host._view = 'list'; host._showColumnSettings = false; }}>← Back +

${f.id ? 'Edit' : 'New'} Form

+
+ ${f.id ? html` + host._viewEntries(f.id)}> + Entries (${host._entryCount ?? 0}) + + { host._showColumnSettings = !host._showColumnSettings; host.requestUpdate(); }}> + ⚙ Settings + + ` : nothing} + host._saveForm()}>Save Form +
+
+ + + ${showSettings && f.id ? html` + ${_renderEmbedSettings(host, f)} + ${_renderColumnSettings(host, f)} + ` : nothing} + + +
+ + + + + + + + +
+ + +
+

Fields

+ host._addField()}>+ Add Field +
+ ${f.fields.map((field, idx) => _renderFieldCard(host, field, idx, needsOptions))} +
+ ${host._typePickerIdx >= 0 ? _renderTypePicker(host) : nothing}`; +} + +/** + * Renders the Embed Code / API settings panel. + */ +function _renderEmbedSettings(host, f) { + return html` +
+
+

Embed API Settings

+
+
+ POST /api/utpro/simple-form/submit { "alias": "${f.alias}", "data": { ... } } +
+
+ { f.enableRenderApi = e.target.checked; host.requestUpdate(); }} + label=${f.enableRenderApi ? 'Enabled' : 'Disabled'}> + GET /api/utpro/simple-form/render/${f.alias} +
+
+ { f.enableEntriesApi = e.target.checked; host.requestUpdate(); }} + label=${f.enableEntriesApi ? 'Enabled' : 'Disabled'}> + GET /api/utpro/simple-form/entries/${f.alias} +
+
`; +} + +/** + * Renders the column visibility settings panel with drag & drop reordering. + */ +function _renderColumnSettings(host, f) { + const allFieldNames = f.fields.map(field => field.name).filter(n => n); + + // Build ordered list: visibleColumns first (in order), then unchecked ones + let orderedNames; + if (f.visibleColumns && f.visibleColumns.length > 0) { + const checked = f.visibleColumns.filter(n => allFieldNames.includes(n)); + const unchecked = allFieldNames.filter(n => !f.visibleColumns.includes(n)); + orderedNames = [...checked, ...unchecked]; + } else { + orderedNames = [...allFieldNames]; + } + + return html` +
+
+

⚙ Entries Column Settings

+ Select which fields to show as columns in the Entries view. Drag to reorder. +
+
+ ${allFieldNames.length === 0 ? html`
No fields yet. Add fields first.
` : nothing} + ${orderedNames.map((name, idx) => { + const isVisible = f.visibleColumns === null || f.visibleColumns === undefined + ? true + : f.visibleColumns.includes(name); + return html` + `; + })} +
+
`; +} + +/** + * Renders the field type picker dialog with search. + */ +function _renderTypePicker(host) { + const search = (host._typePickerSearch || '').toLowerCase(); + const filtered = host._fieldTypes.filter(ft => + ft.label.toLowerCase().includes(search) || ft.type.toLowerCase().includes(search) + ); + const idx = host._typePickerIdx; + const currentType = host._editForm?.fields[idx]?.type; + + return html` +
{ if (e.target === e.currentTarget) { host._typePickerIdx = -1; host.requestUpdate(); } }}> +
+
+

Select Field Type

+ { host._typePickerIdx = -1; host.requestUpdate(); }}>✕ +
+ +
+ ${filtered.map(ft => html` + + `)} + ${filtered.length === 0 ? html`
No matching types
` : nothing} +
+
+
`; +} + +/** + * Renders type-specific attribute fields. + * Uses field.attributes dict to store extra config per type. + */ +function _renderTypeAttributes(host, field, idx) { + const t = field.type; + if (!field.attributes) field.attributes = {}; + const attr = field.attributes; + const setAttr = (key, val) => { field.attributes[key] = val; host.requestUpdate(); }; + + // Number: min, max, step + if (t === 'number') return html` +
+ + + +
`; + + // Date: min, max + if (t === 'date') return html` +
+ + +
`; + + // Time: min, max + if (t === 'time') return html` +
+ + +
`; + + // Textarea: rows + if (t === 'textarea') return html` +
+ +
`; + + // File: accept, maxSize + if (t === 'file') return html` +
+ + +
`; + + // Range: min, max, step + if (t === 'range') return html` +
+ + + +
`; + + // Accept/Terms: text, linkUrl, linkText + if (t === 'accept') return html` +
+ + + +
`; + + // Step: title + if (t === 'step') return html` +
+ +
`; + + return nothing; +} + +function _renderFieldCard(host, field, idx, needsOptions) { + const f = host._editForm; + const currentTypeLabel = host._fieldTypes.find(ft => ft.type === field.type)?.label || field.type; + + return html` +
+
+ #${idx + 1} + + host._updateField(idx, 'isHidden', !e.target.checked)} label=${field.isHidden ? 'Hidden' : 'Visible'}> + ${field.type !== 'div' && field.type !== 'step' ? html` + host._updateField(idx, 'required', e.target.checked)} label="Required"> + host._updateField(idx, 'isSensitive', e.target.checked)} label="Sensitive Data"> + ` : nothing} +
+ host._moveField(idx, -1)} ?disabled=${idx === 0}>▲ + host._moveField(idx, 1)} ?disabled=${idx === f.fields.length - 1}>▼ + host._removeField(idx)}>✕ +
+
+ ${field.type === 'div' || field.type === 'step' ? html` +
+ + +
+ ` : html` +
+ + + + + + + +
+ `} + ${_renderTypeAttributes(host, field, idx)} + ${needsOptions(field.type) ? html` +
+
Options + host._addOption(idx)}>+ Option +
+ ${(field.options || []).map((opt, oIdx) => html` +
+ { opt.text = e.target.value; host.requestUpdate(); }}> + { opt.value = e.target.value; host.requestUpdate(); }}> + host._removeOption(idx, oIdx)}>✕ +
`)} +
` : nothing} +
`; +} diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/entries-view.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/entries-view.js new file mode 100644 index 0000000..a298c69 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/entries-view.js @@ -0,0 +1,100 @@ +import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; + +/** + * Renders the entries list view with search, date filter, and checkboxes. + * @param {object} host - the dashboard element + */ +export function renderEntries(host) { + const isAdmin = host._permissions?.isAdmin; + const form = host._forms.find(f => f.id === host._viewFormId); + const formName = form?.name || 'Form'; + const pages = Math.max(1, Math.ceil(host._entryTotal / 20)); + const page = Math.floor(host._entrySkip / 20) + 1; + const allDataKeys = [...new Set(host._entries.flatMap(s => Object.keys(s.data || {})))]; + const visibleCols = form?.visibleColumns; + const allKeys = visibleCols && visibleCols.length > 0 + ? allDataKeys.filter(k => visibleCols.includes(k)) + : allDataKeys; + const allSelected = host._entries.length > 0 && host._selectedEntries.length === host._entries.length; + + return html` + + +
+ { host._view = 'list'; host._selectedEntries = []; }}>← Back +

Entries: ${formName}

+
+ ${host._entryTotal} entries + ${host._entries.length ? html` + host._exportCsv()}>Export CSV + ` : nothing} + ${isAdmin && host._selectedEntries.length ? html` + host._bulkDelete()}> + Delete (${host._selectedEntries.length}) + + ` : nothing} +
+
+ + +
+ { host._search = e.target.value; }} + @keydown=${(e) => { if (e.key === 'Enter') { host._entrySkip = 0; host._selectedEntries = []; host._loadEntries(); } }}> + +
+ + + { host._search = ''; host._dateFrom = ''; host._dateTo = ''; host._entrySkip = 0; host._selectedEntries = []; host._loadEntries(); }}>Clear +
+
+ + + ${!host._entries.length ? html`
No entries yet
` : html` + + + + host._toggleSelectAll()} /> + + Date + IP + ${allKeys.map(k => html`${k}`)} + Actions + + ${host._entries.map(s => html` + + + host._toggleEntrySelect(s.id)} /> + + ${new Date(s.createdUtc).toLocaleString()} + ${s.ipAddress || ''} + ${allKeys.map(k => html`${s.data?.[k] || ''}`)} + + host._viewDetail(s)} title="View">☰ + ${isAdmin ? html` + host._deleteEntry(s.id)} title="Delete">✕ + ` : nothing} + + `)} + + ${host._entryTotal > 20 ? html` + ` : nothing} + `} +
`; +} diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/list-view.js b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/list-view.js new file mode 100644 index 0000000..81da4cf --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/list-view.js @@ -0,0 +1,53 @@ +import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; + +/** + * Renders the form list view. + * - Admin: can create, edit, delete forms + * - Non-admin: can only view list and access entries + * @param {object} host - the dashboard element + */ +export function renderList(host) { + const isAdmin = host._permissions?.isAdmin; + + return html` + +
+

Form Builder

+ ${isAdmin ? html` + host._newForm()}>+ New Form + ` : nothing} +
+ ${host._loading ? html`
` : nothing} + ${!host._forms.length && !host._loading ? html`
No forms yet.${isAdmin ? ' Create one!' : ''}
` : nothing} + ${host._forms.length ? html` + + + Name + Alias + Fields + Status + Actions + + ${host._forms.map(f => html` + + + ${isAdmin + ? html` host._editExisting(f.id)}>${f.name}` + : html`${f.name}`} + + ${f.alias} + ${f.fields?.length || 0} + ${f.isEnabled ? html`Active` : html`Disabled`} + + ${isAdmin ? html` + host._editExisting(f.id)}>Edit + ` : nothing} + host._viewEntries(f.id)}>Entries + ${isAdmin ? html` + host._deleteForm(f.id)}>Delete + ` : nothing} + + `)} + ` : nothing} +
`; +} diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml index e07ea49..94eecf6 100644 --- a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml @@ -1,4 +1,6 @@ +@using uTPro.Extension.CurrentSite @model uTPro.Feature.SimpleForm.Models.FormViewModel +@inject ICurrentSiteExtension CurrentSite @{ var formId = "sf-" + Model.Alias; var cssClass = ViewBag.FormCssClass as string ?? ""; @@ -7,19 +9,22 @@ var resetText = ViewBag.ResetBtnText as string ?? "RESET"; } - + + -
+
@foreach (var field in Model.Fields.OrderBy(f => f.SortOrder)) { - var span = field.ColSpan >= 2 ? "sf-col-full" : "sf-col-half"; + if (field.IsHidden) { continue; } + var fieldCss = field.CssClass ?? ""; -
+
@{ - // Try custom partial first: Fields/{Type}.cshtml - // Fallback to Fields/_Default.cshtml var customPartial = $"~/Views/Partials/SimpleForm/Fields/{field.Type}.cshtml"; var fallbackPartial = "~/Views/Partials/SimpleForm/Fields/_Default.cshtml"; } @@ -30,136 +35,27 @@ .ContentRootPath, customPartial.Replace("~/", "").Replace("/", System.IO.Path.DirectorySeparatorChar.ToString())))) { - @await Html.PartialAsync(customPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId } }) + @await Html.PartialAsync(customPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) } else { - @await Html.PartialAsync(fallbackPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId } }) + @await Html.PartialAsync(fallbackPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) }
} -
-
- - @if (showReset) - { - - } +
+
    +
  • + @if (showReset) + { +
  • + } +
+
- - - + + diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/_Default.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/_Default.cshtml index 9cdc201..3d5b878 100644 --- a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/_Default.cshtml +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/_Default.cshtml @@ -3,11 +3,33 @@ To add a CUSTOM field type, create a new file in this folder: Fields/{YourType}.cshtml (e.g. Fields/turnstile.cshtml) It receives: Model = FormFieldViewModel, ViewData["FormId"] = form element id + + MULTI-LANGUAGE SUPPORT: + The ValidationMessage field supports both plain text and dictionary keys. + If the value matches a dictionary key, it will be resolved to the + current culture's translation. Otherwise, the raw text is used as-is. ═══════════════════════════════════════════════════════════════ *@ +@using uTPro.Extension.CurrentSite @model uTPro.Feature.SimpleForm.Models.FormFieldViewModel @{ var formId = ViewData["FormId"] as string ?? "sf"; var fieldId = formId + "-" + Model.Name; + var currentSite = ViewData["CurrentSite"] as ICurrentSiteExtension; + // Resolve validation message: + // - Plain text → used as-is (e.g. "Please enter your name") + // - {{key}} → resolved via Umbraco Dictionary (e.g. "{{SimpleForm.NameRequired}}") + var validationMsg = Model.ValidationMessage; + if (currentSite != null + && !string.IsNullOrEmpty(validationMsg) + && validationMsg.StartsWith("{{") && validationMsg.EndsWith("}}")) + { + var dictKey = validationMsg[2..^2].Trim(); + var translated = currentSite.GetDictionaryValue(dictKey); + if (!string.IsNullOrEmpty(translated)) + { + validationMsg = translated; + } + } } @if (Model.Type != "hidden") @@ -18,9 +40,20 @@ } +@{ + var minAttr = Model.Attributes?.GetValueOrDefault("min") ?? ""; + var maxAttr = Model.Attributes?.GetValueOrDefault("max") ?? ""; + var stepAttr = Model.Attributes?.GetValueOrDefault("step") ?? ""; + var acceptAttr = Model.Attributes?.GetValueOrDefault("accept") ?? ""; +} + + @(!string.IsNullOrEmpty(minAttr) ? $"min=\"{minAttr}\"" : "") + @(!string.IsNullOrEmpty(maxAttr) ? $"max=\"{maxAttr}\"" : "") + @(!string.IsNullOrEmpty(stepAttr) ? $"step=\"{stepAttr}\"" : "") + @(!string.IsNullOrEmpty(acceptAttr) ? $"accept=\"{acceptAttr}\"" : "") + data-msg="@validationMsg" /> diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/accept.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/accept.cshtml new file mode 100644 index 0000000..62f1d09 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/accept.cshtml @@ -0,0 +1,22 @@ +@* Accept / Terms checkbox with optional link *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; + var text = Model.Attributes?.GetValueOrDefault("text") ?? "I agree to the"; + var linkUrl = Model.Attributes?.GetValueOrDefault("linkUrl") ?? ""; + var linkText = Model.Attributes?.GetValueOrDefault("linkText") ?? "Terms & Conditions"; +} + + + diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/color.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/color.cshtml new file mode 100644 index 0000000..f4bb19c --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/color.cshtml @@ -0,0 +1,13 @@ +@* Color picker *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; +} + + + + diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/div.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/div.cshtml new file mode 100644 index 0000000..dde07ce --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/div.cshtml @@ -0,0 +1,13 @@ +@* ═══════════════════════════════════════════════════════════════ + CONTENT BLOCK (div) — renders raw HTML from DefaultValue. + Use for headings, paragraphs, instructions, or any custom HTML + within the form layout. +═══════════════════════════════════════════════════════════════ *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel + +@if (!string.IsNullOrEmpty(Model.DefaultValue)) +{ +
+ @Html.Raw(Model.DefaultValue) +
+} diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/range.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/range.cshtml new file mode 100644 index 0000000..64b771b --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/range.cshtml @@ -0,0 +1,19 @@ +@* Range slider with min/max/step *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; + var min = Model.Attributes?.GetValueOrDefault("min") ?? "0"; + var max = Model.Attributes?.GetValueOrDefault("max") ?? "100"; + var step = Model.Attributes?.GetValueOrDefault("step") ?? "1"; + var defaultVal = Model.DefaultValue ?? min; +} + + + + diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/step.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/step.cshtml new file mode 100644 index 0000000..6a6b3c8 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/step.cshtml @@ -0,0 +1,10 @@ +@* Form Step divider — renders a visual step separator *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var title = Model.Attributes?.GetValueOrDefault("title") ?? Model.Label ?? "Next Step"; +} + +
+

@title

+
+
diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/textarea.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/textarea.cshtml index fe49e1c..2ed858c 100644 --- a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/textarea.cshtml +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/textarea.cshtml @@ -1,8 +1,22 @@ +@using uTPro.Extension.CurrentSite @model uTPro.Feature.SimpleForm.Models.FormFieldViewModel @{ var formId = ViewData["FormId"] as string ?? "sf"; var fieldId = formId + "-" + Model.Name; var rows = Model.Attributes?.GetValueOrDefault("rows") ?? "4"; + var currentSite = ViewData["CurrentSite"] as ICurrentSiteExtension; + var validationMsg = Model.ValidationMessage; + if (currentSite != null + && !string.IsNullOrEmpty(validationMsg) + && validationMsg.StartsWith("{{") && validationMsg.EndsWith("}}")) + { + var dictKey = validationMsg[2..^2].Trim(); + var translated = currentSite.GetDictionaryValue(dictKey); + if (!string.IsNullOrEmpty(translated)) + { + validationMsg = translated; + } + } }
- + \ No newline at end of file diff --git a/uTPro/Project/uTPro.Project.Web/Views/uTPro/blockgrid/Components/ContactForm.cshtml b/uTPro/Project/uTPro.Project.Web/Views/uTPro/blockgrid/Components/ContactForm.cshtml index 42ca171..ec994e9 100644 --- a/uTPro/Project/uTPro.Project.Web/Views/uTPro/blockgrid/Components/ContactForm.cshtml +++ b/uTPro/Project/uTPro.Project.Web/Views/uTPro/blockgrid/Components/ContactForm.cshtml @@ -21,7 +21,7 @@ else
@await Component.InvokeAsync("SimpleForm", new { alias = "contact-us" }) -
+ @*
@@ -39,7 +39,7 @@ else
-
+ *@
diff --git a/uTPro/Project/uTPro.Project.Web/appsettings.Development.json b/uTPro/Project/uTPro.Project.Web/appsettings.Development.json index 63198e6..ccaaf16 100644 --- a/uTPro/Project/uTPro.Project.Web/appsettings.Development.json +++ b/uTPro/Project/uTPro.Project.Web/appsettings.Development.json @@ -18,7 +18,7 @@ ] }, "ConnectionStrings": { - "umbracoDbDSN": "Server=(local);Database=uTPro_new;Integrated Security=true;TrustServerCertificate=true;", + "umbracoDbDSN": "Server=(local);Database= ;Integrated Security=true;TrustServerCertificate=true;", "umbracoDbDSN_ProviderName": "Microsoft.Data.SqlClient" }, "uTPro": { diff --git a/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj b/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj index 012858b..d5309f1 100644 --- a/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj +++ b/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj @@ -23,6 +23,14 @@
+ + + + + + + + diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/css/simple-form.css b/uTPro/Project/uTPro.Project.Web/wwwroot/css/simple-form.css new file mode 100644 index 0000000..3d4279b --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/css/simple-form.css @@ -0,0 +1,25 @@ +/* ── SimpleForm Frontend Styles ── */ +.sf { max-width: 100%; } +.sf .sf-error { color: #e53935; font-size: 0.8rem; display: none; } +.sf .sf-error:not(:empty) { display: block; } +.sf .sf-required { color: #e53935; } +.sf-actions { display: flex; gap: 12px; margin-top: 20px; } +.sf-btn { + padding: 14px 40px; font-size: 14px; font-weight: 700; letter-spacing: 2px; + text-transform: uppercase; border: 2px solid #555; border-radius: 4px; cursor: pointer; + transition: all 0.2s; +} +.sf-message { margin-top: 16px; padding: 12px; border-radius: 4px; } +.sf-success { background: #e8fde8; color: #27ae60; } +.sf-fail { background: #fde8e8; color: #c0392b; } + +/* ── Content Block ── */ +.sf-content-block { margin: 8px 0; } + +/* ── Step divider ── */ +.sf-step { margin: 16px 0 8px; } +.sf-step-title { font-size: 1.1rem; margin: 0 0 8px; } +.sf-step-divider { border: none; border-top: 1px solid #ddd; } + +/* ── Range ── */ +.sf-range-value { font-weight: 600; margin-left: 8px; } diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/css/uTPro/main.css b/uTPro/Project/uTPro.Project.Web/wwwroot/css/uTPro/main.css index 33ba42c..97d7fe1 100644 --- a/uTPro/Project/uTPro.Project.Web/wwwroot/css/uTPro/main.css +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/css/uTPro/main.css @@ -102,11 +102,6 @@ form textarea { background: #f8f8f8; } - form input[type="text"], - form input[type="email"] input[type="password"] { - line-height: 1em; - } - form select { line-height: 1em; } diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/js/simple-form.js b/uTPro/Project/uTPro.Project.Web/wwwroot/js/simple-form.js new file mode 100644 index 0000000..8ebfe1e --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/js/simple-form.js @@ -0,0 +1,94 @@ +/** + * SimpleForm — client-side validation & submission handler. + * Reads config from data attributes on the
element: + * data-alias — form alias (required) + * data-redirect — redirect URL after success (optional) + * data-btn-text — submit button text for reset after submit (optional) + */ +(function () { + document.querySelectorAll('form.sf').forEach(function (form) { + var alias = form.dataset.alias; + if (!alias) return; + + var redirectUrl = form.dataset.redirect || ''; + var btnText = form.dataset.btnText || 'Submit'; + + form.addEventListener('submit', async function (e) { + e.preventDefault(); + var msgEl = form.querySelector('.sf-message'); + var btn = form.querySelector('[type="submit"]'); + form.querySelectorAll('.sf-error').forEach(function (el) { el.textContent = ''; }); + if (msgEl) msgEl.style.display = 'none'; + + var data = {}; + var valid = true; + var inputs = form.querySelectorAll('[name]'); + inputs.forEach(function (input) { + if (input.name === '__alias') return; + var name = input.name; + if (input.type === 'checkbox') { + var checked = form.querySelectorAll('input[name="' + name + '"]:checked'); + if (checked.length > 0) data[name] = Array.from(checked).map(function (c) { return c.value; }).join(', '); + } else if (input.type === 'radio') { + var sel = form.querySelector('input[name="' + name + '"]:checked'); + if (sel) data[name] = sel.value; + } else if (input.type !== 'file') { + data[name] = input.value; + } + if (input.hasAttribute('required') && !data[name]) { + var errEl = form.querySelector('.sf-error[data-for="' + name + '"]'); + if (errEl) errEl.textContent = input.dataset.msg || 'Required'; + valid = false; + } + if (input.pattern && data[name]) { + if (!new RegExp(input.pattern).test(data[name])) { + var errEl2 = form.querySelector('.sf-error[data-for="' + name + '"]'); + if (errEl2) errEl2.textContent = input.dataset.msg || 'Invalid'; + valid = false; + } + } + }); + + // Hook: allow custom field types to inject validation/data + if (window.__sfBeforeSubmit) { + var hookResult = await window.__sfBeforeSubmit(alias, data, form); + if (hookResult === false) return; + if (typeof hookResult === 'object') Object.assign(data, hookResult); + } + + if (!valid) return; + if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; } + try { + var resp = await fetch('/api/utpro/simple-form/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ alias: alias, data: data }) + }); + var result = await resp.json(); + if (resp.ok) { + if (redirectUrl) { window.location.href = redirectUrl; return; } + if (msgEl) { + msgEl.className = 'sf-message sf-success'; + msgEl.textContent = result.message || 'Thank you!'; + msgEl.style.display = 'block'; + } + form.reset(); + if (window.__sfAfterSubmit) window.__sfAfterSubmit(alias, true, result); + } else { + if (msgEl) { + msgEl.className = 'sf-message sf-fail'; + msgEl.textContent = result.message || 'Error'; + msgEl.style.display = 'block'; + } + } + } catch (err) { + if (msgEl) { + msgEl.className = 'sf-message sf-fail'; + msgEl.textContent = 'Network error'; + msgEl.style.display = 'block'; + } + } + if (btn) { btn.disabled = false; btn.textContent = btnText; } + }); + }); +})(); From 0763265da381a113984164c713e5afac47bd5e10 Mon Sep 17 00:00:00 2001 From: Nguyen Thien Tu <15050790+thientu995@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:52:09 +0700 Subject: [PATCH 3/9] Move file to projevgt Simple Form --- .../ViewComponents/SimpleFormViewComponent.cs | 18 +- .../Views/Partials/SimpleForm/Default.cshtml | 59 +++ .../SimpleForm/Fields/_Default.cshtml | 59 +++ .../Partials/SimpleForm/Fields/accept.cshtml | 22 ++ .../SimpleForm/Fields/checkbox.cshtml | 31 ++ .../Partials/SimpleForm/Fields/color.cshtml | 13 + .../Partials/SimpleForm/Fields/div.cshtml | 13 + .../Partials/SimpleForm/Fields/hidden.cshtml | 2 + .../Partials/SimpleForm/Fields/radio.cshtml | 26 ++ .../Partials/SimpleForm/Fields/range.cshtml | 19 + .../Partials/SimpleForm/Fields/select.cshtml | 22 ++ .../Partials/SimpleForm/Fields/step.cshtml | 10 + .../SimpleForm/Fields/textarea.cshtml | 31 ++ .../Partials/SimpleForm/Fields/time.cshtml | 20 + .../Views/_ViewImports.cshtml | 4 + .../uTPro.Feature.SimpleForm.csproj | 38 +- .../wwwroot}/App_Plugins/simple-form/api.js | 0 .../wwwroot}/App_Plugins/simple-form/index.js | 0 .../App_Plugins/simple-form/lang/en-us.js | 0 .../App_Plugins/simple-form/styles.js | 0 .../simple-form/umbraco-package.json | 0 .../simple-form/views/detail-view.js | 0 .../simple-form/views/editor-view.js | 1 + .../simple-form/views/entries-view.js | 0 .../simple-form/views/list-view.js | 0 .../uTPro/simple-form}/css/simple-form.css | 0 .../uTPro/simple-form}/js/simple-form.js | 1 - .../Views/Partials/SimpleForm/Default.cshtml | 14 +- .../Partials/SimpleForm/Fields/README.md | 65 ---- .../uTPro.Project.Web.csproj | 13 - .../wwwroot/App_Plugins/simple-form/api.js | 29 ++ .../wwwroot/App_Plugins/simple-form/index.js | 309 ++++++++++++++++ .../App_Plugins/simple-form/lang/en-us.js | 5 + .../wwwroot/App_Plugins/simple-form/styles.js | 205 +++++++++++ .../simple-form/umbraco-package.json | 31 ++ .../simple-form/views/detail-view.js | 44 +++ .../simple-form/views/editor-view.js | 342 ++++++++++++++++++ .../simple-form/views/entries-view.js | 100 +++++ .../simple-form/views/list-view.js | 53 +++ .../uTPro/simple-form/css/simple-form.css | 25 ++ .../uTPro/simple-form/js/simple-form.js | 93 +++++ 41 files changed, 1615 insertions(+), 102 deletions(-) create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Default.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/_Default.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/accept.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/checkbox.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/color.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/div.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/hidden.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/radio.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/range.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/select.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/step.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/textarea.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/time.cshtml create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/_ViewImports.cshtml rename uTPro/{Project/uTPro.Project.Web => Feature/uTPro.Feature.SimpleForm/wwwroot}/App_Plugins/simple-form/api.js (100%) rename uTPro/{Project/uTPro.Project.Web => Feature/uTPro.Feature.SimpleForm/wwwroot}/App_Plugins/simple-form/index.js (100%) rename uTPro/{Project/uTPro.Project.Web => Feature/uTPro.Feature.SimpleForm/wwwroot}/App_Plugins/simple-form/lang/en-us.js (100%) rename uTPro/{Project/uTPro.Project.Web => Feature/uTPro.Feature.SimpleForm/wwwroot}/App_Plugins/simple-form/styles.js (100%) rename uTPro/{Project/uTPro.Project.Web => Feature/uTPro.Feature.SimpleForm/wwwroot}/App_Plugins/simple-form/umbraco-package.json (100%) rename uTPro/{Project/uTPro.Project.Web => Feature/uTPro.Feature.SimpleForm/wwwroot}/App_Plugins/simple-form/views/detail-view.js (100%) rename uTPro/{Project/uTPro.Project.Web => Feature/uTPro.Feature.SimpleForm/wwwroot}/App_Plugins/simple-form/views/editor-view.js (99%) rename uTPro/{Project/uTPro.Project.Web => Feature/uTPro.Feature.SimpleForm/wwwroot}/App_Plugins/simple-form/views/entries-view.js (100%) rename uTPro/{Project/uTPro.Project.Web => Feature/uTPro.Feature.SimpleForm/wwwroot}/App_Plugins/simple-form/views/list-view.js (100%) rename uTPro/{Project/uTPro.Project.Web/wwwroot => Feature/uTPro.Feature.SimpleForm/wwwroot/uTPro/simple-form}/css/simple-form.css (100%) rename uTPro/{Project/uTPro.Project.Web/wwwroot => Feature/uTPro.Feature.SimpleForm/wwwroot/uTPro/simple-form}/js/simple-form.js (98%) delete mode 100644 uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/README.md create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/api.js create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/index.js create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/lang/en-us.js create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/umbraco-package.json create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/detail-view.js create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/entries-view.js create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/list-view.js create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/css/simple-form.css create mode 100644 uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/js/simple-form.js diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs index 7e89364..f9ec59f 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/ViewComponents/SimpleFormViewComponent.cs @@ -6,13 +6,6 @@ namespace uTPro.Feature.SimpleForm.ViewComponents; public class SimpleFormViewComponent(ISimpleFormService formService, IWebHostEnvironment env) : ViewComponent { - /// - /// Renders a SimpleForm by alias. - /// Template resolution order: - /// 1. Explicit template parameter: ~/Views/Partials/SimpleForm/{template}.cshtml - /// 2. Form-specific template: ~/Views/Partials/SimpleForm/{alias}.cshtml - /// 3. Default template: ~/Views/Partials/SimpleForm/Default.cshtml - /// public IViewComponentResult Invoke( string alias, string? template = null, @@ -36,22 +29,19 @@ public IViewComponentResult Invoke( private string ResolveTemplate(string? template, string alias) { - // 1. Explicit template if (!string.IsNullOrEmpty(template)) { - var explicitPath = $"~/Views/Partials/SimpleForm/{template}.cshtml"; - if (ViewExists(explicitPath)) return explicitPath; + var path = $"~/Views/Partials/SimpleForm/{template}.cshtml"; + if (FileExists(path)) return path; } - // 2. Form-specific template (by alias) var aliasPath = $"~/Views/Partials/SimpleForm/{alias}.cshtml"; - if (ViewExists(aliasPath)) return aliasPath; + if (FileExists(aliasPath)) return aliasPath; - // 3. Default return "~/Views/Partials/SimpleForm/Default.cshtml"; } - private bool ViewExists(string viewPath) + private bool FileExists(string viewPath) { var physicalPath = Path.Combine( env.ContentRootPath, diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Default.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Default.cshtml new file mode 100644 index 0000000..6f140db --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Default.cshtml @@ -0,0 +1,59 @@ +@using uTPro.Extension.CurrentSite +@model uTPro.Feature.SimpleForm.Models.FormViewModel +@inject ICurrentSiteExtension CurrentSite +@{ + var formId = "sf-" + Model.Alias; + var cssClass = ViewBag.FormCssClass as string ?? ""; + var btnText = ViewBag.SubmitBtnText as string ?? "SEND"; + var showReset = ViewBag.ShowReset as bool? ?? true; + var resetText = ViewBag.ResetBtnText as string ?? "RESET"; +} + + + + + +
+ @foreach (var field in Model.Fields.OrderBy(f => f.SortOrder)) + { + if (field.IsHidden) { continue; } + + var fieldCss = field.CssClass ?? ""; + +
+ @{ + var customPartial = $"~/Views/Partials/SimpleForm/Fields/{field.Type}.cshtml"; + var fallbackPartial = "~/Views/Partials/SimpleForm/Fields/_Default.cshtml"; + var viewEngine = ViewContext.HttpContext.RequestServices.GetRequiredService(); + var viewExists = viewEngine.GetView(null, customPartial, false).Success + || viewEngine.FindView(ViewContext, customPartial, false).Success; + } + @if (viewExists) + { + @await Html.PartialAsync(customPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) + } + else + { + @await Html.PartialAsync(fallbackPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) + } +
+ } + +
+
    +
  • + @if (showReset) + { +
  • + } +
+
+
+ +
+ + + diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/_Default.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/_Default.cshtml new file mode 100644 index 0000000..3d5b878 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/_Default.cshtml @@ -0,0 +1,59 @@ +@* ═══════════════════════════════════════════════════════════════ + DEFAULT FIELD PARTIAL — handles: text, email, tel, number, date, url, password + To add a CUSTOM field type, create a new file in this folder: + Fields/{YourType}.cshtml (e.g. Fields/turnstile.cshtml) + It receives: Model = FormFieldViewModel, ViewData["FormId"] = form element id + + MULTI-LANGUAGE SUPPORT: + The ValidationMessage field supports both plain text and dictionary keys. + If the value matches a dictionary key, it will be resolved to the + current culture's translation. Otherwise, the raw text is used as-is. +═══════════════════════════════════════════════════════════════ *@ +@using uTPro.Extension.CurrentSite +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; + var currentSite = ViewData["CurrentSite"] as ICurrentSiteExtension; + // Resolve validation message: + // - Plain text → used as-is (e.g. "Please enter your name") + // - {{key}} → resolved via Umbraco Dictionary (e.g. "{{SimpleForm.NameRequired}}") + var validationMsg = Model.ValidationMessage; + if (currentSite != null + && !string.IsNullOrEmpty(validationMsg) + && validationMsg.StartsWith("{{") && validationMsg.EndsWith("}}")) + { + var dictKey = validationMsg[2..^2].Trim(); + var translated = currentSite.GetDictionaryValue(dictKey); + if (!string.IsNullOrEmpty(translated)) + { + validationMsg = translated; + } + } +} + +@if (Model.Type != "hidden") +{ + +} + +@{ + var minAttr = Model.Attributes?.GetValueOrDefault("min") ?? ""; + var maxAttr = Model.Attributes?.GetValueOrDefault("max") ?? ""; + var stepAttr = Model.Attributes?.GetValueOrDefault("step") ?? ""; + var acceptAttr = Model.Attributes?.GetValueOrDefault("accept") ?? ""; +} + + + diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/accept.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/accept.cshtml new file mode 100644 index 0000000..62f1d09 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/accept.cshtml @@ -0,0 +1,22 @@ +@* Accept / Terms checkbox with optional link *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; + var text = Model.Attributes?.GetValueOrDefault("text") ?? "I agree to the"; + var linkUrl = Model.Attributes?.GetValueOrDefault("linkUrl") ?? ""; + var linkText = Model.Attributes?.GetValueOrDefault("linkText") ?? "Terms & Conditions"; +} + + + diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/checkbox.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/checkbox.cshtml new file mode 100644 index 0000000..36400fe --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/checkbox.cshtml @@ -0,0 +1,31 @@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; +} + +@if (Model.Options != null && Model.Options.Any()) +{ + +
+ @foreach (var opt in Model.Options) + { + var cbId = fieldId + "-" + opt.Value; + + } +
+} +else +{ + +} + diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/color.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/color.cshtml new file mode 100644 index 0000000..f4bb19c --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/color.cshtml @@ -0,0 +1,13 @@ +@* Color picker *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; +} + + + + diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/div.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/div.cshtml new file mode 100644 index 0000000..dde07ce --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/div.cshtml @@ -0,0 +1,13 @@ +@* ═══════════════════════════════════════════════════════════════ + CONTENT BLOCK (div) — renders raw HTML from DefaultValue. + Use for headings, paragraphs, instructions, or any custom HTML + within the form layout. +═══════════════════════════════════════════════════════════════ *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel + +@if (!string.IsNullOrEmpty(Model.DefaultValue)) +{ +
+ @Html.Raw(Model.DefaultValue) +
+} diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/hidden.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/hidden.cshtml new file mode 100644 index 0000000..c7a5db4 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/hidden.cshtml @@ -0,0 +1,2 @@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel + diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/radio.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/radio.cshtml new file mode 100644 index 0000000..30a0eb4 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/radio.cshtml @@ -0,0 +1,26 @@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; +} + + +
+ @if (Model.Options != null) + { + @foreach (var opt in Model.Options) + { + var radioId = fieldId + "-" + opt.Value; + var isChecked = opt.Value == Model.DefaultValue; + + } + } +
+ diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/range.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/range.cshtml new file mode 100644 index 0000000..64b771b --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/range.cshtml @@ -0,0 +1,19 @@ +@* Range slider with min/max/step *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; + var min = Model.Attributes?.GetValueOrDefault("min") ?? "0"; + var max = Model.Attributes?.GetValueOrDefault("max") ?? "100"; + var step = Model.Attributes?.GetValueOrDefault("step") ?? "1"; + var defaultVal = Model.DefaultValue ?? min; +} + + + + diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/select.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/select.cshtml new file mode 100644 index 0000000..65c71c3 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/select.cshtml @@ -0,0 +1,22 @@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; +} + + + + diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/step.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/step.cshtml new file mode 100644 index 0000000..6a6b3c8 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/step.cshtml @@ -0,0 +1,10 @@ +@* Form Step divider — renders a visual step separator *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var title = Model.Attributes?.GetValueOrDefault("title") ?? Model.Label ?? "Next Step"; +} + +
+

@title

+
+
diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/textarea.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/textarea.cshtml new file mode 100644 index 0000000..2ed858c --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/textarea.cshtml @@ -0,0 +1,31 @@ +@using uTPro.Extension.CurrentSite +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; + var rows = Model.Attributes?.GetValueOrDefault("rows") ?? "4"; + var currentSite = ViewData["CurrentSite"] as ICurrentSiteExtension; + var validationMsg = Model.ValidationMessage; + if (currentSite != null + && !string.IsNullOrEmpty(validationMsg) + && validationMsg.StartsWith("{{") && validationMsg.EndsWith("}}")) + { + var dictKey = validationMsg[2..^2].Trim(); + var translated = currentSite.GetDictionaryValue(dictKey); + if (!string.IsNullOrEmpty(translated)) + { + validationMsg = translated; + } + } +} + + + + diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/time.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/time.cshtml new file mode 100644 index 0000000..2aae65f --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Fields/time.cshtml @@ -0,0 +1,20 @@ +@* Time picker *@ +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ + var formId = ViewData["FormId"] as string ?? "sf"; + var fieldId = formId + "-" + Model.Name; + var min = Model.Attributes?.GetValueOrDefault("min") ?? ""; + var max = Model.Attributes?.GetValueOrDefault("max") ?? ""; +} + + + + diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/_ViewImports.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/_ViewImports.cshtml new file mode 100644 index 0000000..4028d16 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Mvc +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Microsoft.Extensions.DependencyInjection +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj b/uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj index 71c2ef1..4b26dd0 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/uTPro.Feature.SimpleForm.csproj @@ -1,9 +1,13 @@ - + net9.0 enable enable + true + + true + ..\..\Project\uTPro.Project.Web @@ -12,6 +16,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/api.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/api.js similarity index 100% rename from uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/api.js rename to uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/api.js diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/index.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/index.js similarity index 100% rename from uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/index.js rename to uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/index.js diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/lang/en-us.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/lang/en-us.js similarity index 100% rename from uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/lang/en-us.js rename to uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/lang/en-us.js diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/styles.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/styles.js similarity index 100% rename from uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/styles.js rename to uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/styles.js diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/umbraco-package.json b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/umbraco-package.json similarity index 100% rename from uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/umbraco-package.json rename to uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/umbraco-package.json diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/detail-view.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/detail-view.js similarity index 100% rename from uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/detail-view.js rename to uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/detail-view.js diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/editor-view.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js similarity index 99% rename from uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/editor-view.js rename to uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js index 94935b1..dc31434 100644 --- a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/editor-view.js +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js @@ -270,6 +270,7 @@ function _renderTypeAttributes(host, field, idx) { return nothing; } + function _renderFieldCard(host, field, idx, needsOptions) { const f = host._editForm; const currentTypeLabel = host._fieldTypes.find(ft => ft.type === field.type)?.label || field.type; diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/entries-view.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/entries-view.js similarity index 100% rename from uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/entries-view.js rename to uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/entries-view.js diff --git a/uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/list-view.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/list-view.js similarity index 100% rename from uTPro/Project/uTPro.Project.Web/App_Plugins/simple-form/views/list-view.js rename to uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/list-view.js diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/css/simple-form.css b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/uTPro/simple-form/css/simple-form.css similarity index 100% rename from uTPro/Project/uTPro.Project.Web/wwwroot/css/simple-form.css rename to uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/uTPro/simple-form/css/simple-form.css diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/js/simple-form.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/uTPro/simple-form/js/simple-form.js similarity index 98% rename from uTPro/Project/uTPro.Project.Web/wwwroot/js/simple-form.js rename to uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/uTPro/simple-form/js/simple-form.js index 8ebfe1e..70dbd76 100644 --- a/uTPro/Project/uTPro.Project.Web/wwwroot/js/simple-form.js +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/uTPro/simple-form/js/simple-form.js @@ -49,7 +49,6 @@ } }); - // Hook: allow custom field types to inject validation/data if (window.__sfBeforeSubmit) { var hookResult = await window.__sfBeforeSubmit(alias, data, form); if (hookResult === false) return; diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml index 94eecf6..6f140db 100644 --- a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml @@ -27,13 +27,11 @@ @{ var customPartial = $"~/Views/Partials/SimpleForm/Fields/{field.Type}.cshtml"; var fallbackPartial = "~/Views/Partials/SimpleForm/Fields/_Default.cshtml"; + var viewEngine = ViewContext.HttpContext.RequestServices.GetRequiredService(); + var viewExists = viewEngine.GetView(null, customPartial, false).Success + || viewEngine.FindView(ViewContext, customPartial, false).Success; } - @if (System.IO.File.Exists( - System.IO.Path.Combine( - ViewContext.HttpContext.RequestServices - .GetRequiredService() - .ContentRootPath, - customPartial.Replace("~/", "").Replace("/", System.IO.Path.DirectorySeparatorChar.ToString())))) + @if (viewExists) { @await Html.PartialAsync(customPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) } @@ -57,5 +55,5 @@ - - + + diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/README.md b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/README.md deleted file mode 100644 index 50c7a1b..0000000 --- a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Fields/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# SimpleForm Custom Field Types - -To add a custom field type (e.g. Cloudflare Turnstile, encrypted field, rating stars, etc.): - -## 1. Create a partial view - -Create a `.cshtml` file in this folder named after your field type: - -``` -Fields/turnstile.cshtml -Fields/rating.cshtml -Fields/encrypted.cshtml -``` - -## 2. Partial view receives - -- `@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel` -- `ViewData["FormId"]` — the form element ID (string) -- `Model.Attributes` — Dictionary for custom config (e.g. siteKey, theme) - -## 3. Example: Cloudflare Turnstile - -Create `Fields/turnstile.cshtml`: - -```html -@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel -@{ var siteKey = Model.Attributes?.GetValueOrDefault("siteKey") ?? ""; } - -
- - - - -``` - -Then in backoffice Form Builder, add a field with: -- Type: `turnstile` -- Attributes: `siteKey` = `0x4AAAAAAA...` - -## 4. JS Hooks - -You can hook into form submission from any custom field: - -```js -// Called before submit — return false to cancel, or object to merge extra data -window.__sfBeforeSubmit = async function(alias, data, formEl) { - // e.g. validate turnstile token - if (!data['cf-turnstile']) return false; - return data; -}; - -// Called after successful submit -window.__sfAfterSubmit = function(alias, success, result) { - // e.g. reset turnstile widget -}; -``` - -## 5. Register in backoffice (optional) - -To make your custom type appear in the backoffice field type dropdown, -add it via the `/field-types` API endpoint in `SimpleFormApiController.cs`. diff --git a/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj b/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj index d5309f1..d17d510 100644 --- a/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj +++ b/uTPro/Project/uTPro.Project.Web/uTPro.Project.Web.csproj @@ -13,23 +13,10 @@
- - - - - - - - - - - - - diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/api.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/api.js new file mode 100644 index 0000000..77fe91e --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/api.js @@ -0,0 +1,29 @@ +// ── API helper & constants ── +export const API = '/umbraco/management/api/v1/utpro/simple-form'; + +/** + * Make an authenticated POST request to the backoffice API. + * @param {string} url + * @param {object} body + * @param {object} authContext - UMB_AUTH_CONTEXT instance + * @returns {Promise} + */ +export async function apiPost(url, body = {}, authContext = null) { + const config = authContext?.getOpenApiConfiguration(); + const headers = { 'Content-Type': 'application/json' }; + if (config?.token) { + const t = await config.token(); + if (t) headers['Authorization'] = 'Bearer ' + t; + } + const resp = await fetch(url, { + method: 'POST', + headers, + credentials: config?.credentials || 'same-origin', + body: JSON.stringify(body) + }); + if (!resp.ok) { + const e = await resp.json().catch(() => ({})); + throw new Error(e.message || 'Failed'); + } + return resp.json(); +} diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/index.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/index.js new file mode 100644 index 0000000..aada3ba --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/index.js @@ -0,0 +1,309 @@ +// ── Entry point: Simple Form Dashboard ── +// Views and styles are split into separate files for maintainability. +// api.js – API helper & constants +// styles.js – All CSS styles +// views/list-view.js – Form list +// views/editor-view.js – Form editor +// views/entries-view.js – Entries table +// views/detail-view.js – Entry detail overlay + +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; + +import { API, apiPost } from './api.js'; +import { dashboardStyles } from './styles.js'; +import { renderList } from './views/list-view.js'; +import { renderEditor } from './views/editor-view.js'; +import { renderEntries } from './views/entries-view.js'; +import { renderDetail } from './views/detail-view.js'; + +export class UtproSimpleFormDashboard extends UmbLitElement { + + // ── Reactive properties ── + static properties = { + _view: { type: String, state: true }, + _forms: { type: Array, state: true }, + _loading: { type: Boolean, state: true }, + _editForm: { type: Object, state: true }, + _fieldTypes: { type: Array, state: true }, + _entries: { type: Array, state: true }, + _entryTotal: { type: Number, state: true }, + _entrySkip: { type: Number, state: true }, + _viewFormId: { type: Number, state: true }, + _error: { type: String, state: true }, + _success: { type: String, state: true }, + _selectedEntries: { type: Array, state: true }, + _detailEntry: { type: Object, state: true }, + _permissions: { type: Object, state: true }, + _search: { type: String, state: true }, + _dateFrom: { type: String, state: true }, + _dateTo: { type: String, state: true }, + _showColumnSettings: { type: Boolean, state: true }, + _entryCount: { type: Number, state: true }, + _typePickerIdx: { type: Number, state: true }, + _typePickerSearch: { type: String, state: true }, + }; + + // ── Styles ── + static styles = dashboardStyles; + + #authContext; + + constructor() { + super(); + this._view = 'list'; + this._forms = []; + this._loading = false; + this._editForm = null; + this._fieldTypes = []; + this._entries = []; + this._entryTotal = 0; + this._entrySkip = 0; + this._viewFormId = 0; + this._error = ''; + this._success = ''; + this._selectedEntries = []; + this._detailEntry = null; + this._permissions = { isAdmin: false, canViewSensitive: false }; + this._search = ''; + this._dateFrom = ''; + this._dateTo = ''; + this._showColumnSettings = false; + this._entryCount = 0; + this._typePickerIdx = -1; + this._typePickerSearch = ''; + this.consumeContext(UMB_AUTH_CONTEXT, (ctx) => { this.#authContext = ctx; }); + } + + async connectedCallback() { + super.connectedCallback(); + await this._loadPermissions(); + await this._loadForms(); + await this._loadFieldTypes(); + } + + // ── API helper ── + async _api(url, body = {}) { + return apiPost(url, body, this.#authContext); + } + + _msg(m, err = false) { + if (err) { this._error = m; this._success = ''; } + else { this._success = m; this._error = ''; } + setTimeout(() => { this._error = ''; this._success = ''; }, 3000); + } + + // ── Permissions ── + async _loadPermissions() { + try { + this._permissions = await this._api(API + '/permissions'); + } catch { + this._permissions = { isAdmin: false, canViewSensitive: false }; + } + } + + // ── Data loading ── + async _loadForms() { + this._loading = true; + try { this._forms = await this._api(API + '/list'); } + catch (e) { this._msg(e.message, true); } + this._loading = false; + } + + async _loadFieldTypes() { + try { this._fieldTypes = await this._api(API + '/field-types'); } catch {} + } + + // ── Form CRUD ── + _newForm() { + this._editForm = { + id: 0, name: '', alias: '', fields: [], + successMessage: 'Thank you!', redirectUrl: '', emailTo: '', emailSubject: '', + storeEntries: true, isEnabled: true + }; + this._view = 'edit'; + } + + async _editExisting(id) { + try { + this._editForm = await this._api(API + '/get', { id }); + this._showColumnSettings = false; + const res = await this._api(API + '/entries', { formId: id, skip: 0, take: 1 }); + this._entryCount = res.total || 0; + this._view = 'edit'; + } catch (e) { this._msg(e.message, true); } + } + + async _saveForm() { + if (!this._editForm.name || !this._editForm.alias) { + this._msg('Name and Alias required', true); + return; + } + try { + const res = await this._api(API + '/save', this._editForm); + this._msg(res.message); + this._editForm.id = res.id; + await this._loadForms(); + } catch (e) { this._msg(e.message, true); } + } + + async _deleteForm(id) { + if (!confirm('Delete this form and all entries?')) return; + try { + await this._api(API + '/delete', { id }); + this._msg('Deleted'); + await this._loadForms(); + if (this._editForm?.id === id) { this._editForm = null; this._view = 'list'; } + } catch (e) { this._msg(e.message, true); } + } + + // ── Field management ── + _addField() { + const f = this._editForm; + const idx = f.fields.length; + f.fields = [...f.fields, { + id: crypto.randomUUID?.() || Date.now().toString(36), + type: 'text', label: '', name: 'field_' + idx, + placeholder: '', cssClass: '', required: false, + validation: '', validationMessage: '', defaultValue: '', + options: [], sortOrder: idx, colSpan: 1, attributes: {} + }]; + this.requestUpdate(); + } + + _removeField(idx) { + const removedName = this._editForm.fields[idx]?.name; + this._editForm.fields = this._editForm.fields.filter((_, i) => i !== idx); + if (removedName && this._editForm.visibleColumns) { + this._editForm.visibleColumns = this._editForm.visibleColumns.filter(c => c !== removedName); + } + this.requestUpdate(); + } + + _moveField(idx, dir) { + const arr = [...this._editForm.fields]; + const newIdx = idx + dir; + if (newIdx < 0 || newIdx >= arr.length) return; + [arr[idx], arr[newIdx]] = [arr[newIdx], arr[idx]]; + arr.forEach((f, i) => f.sortOrder = i); + this._editForm.fields = arr; + this.requestUpdate(); + } + + _updateField(idx, key, val) { + this._editForm.fields[idx][key] = val; + if (key === 'type' && val === 'password') { + this._editForm.fields[idx].isSensitive = true; + } + this.requestUpdate(); + } + + _addOption(idx) { + if (!this._editForm.fields[idx].options) this._editForm.fields[idx].options = []; + this._editForm.fields[idx].options.push({ text: '', value: '' }); + this.requestUpdate(); + } + + _removeOption(fIdx, oIdx) { + this._editForm.fields[fIdx].options.splice(oIdx, 1); + this.requestUpdate(); + } + + // ── Entries ── + async _viewEntries(formId) { + this._viewFormId = formId; + this._entrySkip = 0; + this._search = ''; + this._dateFrom = ''; + this._dateTo = ''; + this._selectedEntries = []; + this._view = 'entries'; + await this._loadEntries(); + } + + async _loadEntries() { + try { + const body = { + formId: this._viewFormId, skip: this._entrySkip, take: 20 + }; + if (this._search) body.search = this._search; + if (this._dateFrom) body.dateFrom = this._dateFrom; + if (this._dateTo) body.dateTo = this._dateTo; + const res = await this._api(API + '/entries', body); + this._entries = res.items || []; + this._entryTotal = res.total || 0; + } catch (e) { this._msg(e.message, true); } + } + + async _deleteEntry(id) { + if (!confirm('Delete this entry?')) return; + try { + await this._api(API + '/delete-entry', { id }); + this._msg('Deleted'); + this._selectedEntries = this._selectedEntries.filter(x => x !== id); + await this._loadEntries(); + } catch (e) { this._msg(e.message, true); } + } + + _toggleEntrySelect(id) { + if (this._selectedEntries.includes(id)) + this._selectedEntries = this._selectedEntries.filter(x => x !== id); + else + this._selectedEntries = [...this._selectedEntries, id]; + } + + _toggleSelectAll() { + if (this._selectedEntries.length === this._entries.length) + this._selectedEntries = []; + else + this._selectedEntries = this._entries.map(s => s.id); + } + + async _bulkDelete() { + if (!this._selectedEntries.length) return; + if (!confirm('Delete ' + this._selectedEntries.length + ' entries?')) return; + for (const id of this._selectedEntries) { + try { await this._api(API + '/delete-entry', { id }); } catch {} + } + this._selectedEntries = []; + this._msg('Deleted'); + await this._loadEntries(); + } + + _exportCsv() { + if (!this._entries.length) return; + const allKeys = [...new Set(this._entries.flatMap(s => Object.keys(s.data || {})))]; + const headers = ['Date', 'IP', ...allKeys]; + const rows = this._entries.map(s => { + const date = new Date(s.createdUtc).toLocaleString(); + const ip = s.ipAddress || ''; + const fields = allKeys.map(k => '"' + (s.data?.[k] || '').replace(/"/g, '""') + '"'); + return ['"' + date + '"', '"' + ip + '"', ...fields].join(','); + }); + const csv = headers.join(',') + '\n' + rows.join('\n'); + const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + const formName = this._forms.find(f => f.id === this._viewFormId)?.alias || 'form'; + a.href = url; a.download = formName + '-entries.csv'; a.click(); + URL.revokeObjectURL(url); + } + + _viewDetail(entry) { this._detailEntry = entry; } + _closeDetail() { this._detailEntry = null; } + + // ── Render ── + render() { + return html` + ${this._error ? html`
${this._error}
` : nothing} + ${this._success ? html`
${this._success}
` : nothing} + ${this._view === 'list' ? renderList(this) + : this._view === 'edit' ? renderEditor(this) + : renderEntries(this)} + ${this._detailEntry ? renderDetail(this) : nothing}`; + } +} + +customElements.define('utpro-simple-form-dashboard', UtproSimpleFormDashboard); +export default UtproSimpleFormDashboard; diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/lang/en-us.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/lang/en-us.js new file mode 100644 index 0000000..1818b14 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/lang/en-us.js @@ -0,0 +1,5 @@ +export default { + simpleForm: { + title: "Form Builder", + } +} diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js new file mode 100644 index 0000000..8c50c36 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js @@ -0,0 +1,205 @@ +import { css } from '@umbraco-cms/backoffice/external/lit'; + +export const dashboardStyles = css` + :host { display: block; padding: 20px; } + + /* ── Toolbar ── */ + .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; flex-wrap: wrap; } + .toolbar h2 { margin: 0; font-size: 1.3rem; } + .toolbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; } + + /* ── Messages ── */ + .msg { padding: 8px 14px; border-radius: 4px; margin-bottom: 10px; font-size: 0.9rem; } + .error { background: #fde8e8; color: #c0392b; } + .success { background: #e8fde8; color: #27ae60; } + + /* ── States ── */ + .loading { display: flex; justify-content: center; padding: 40px; } + .empty { text-align: center; padding: 40px; color: #888; font-style: italic; } + + /* ── List view ── */ + .link { color: var(--uui-color-interactive, #1b264f); cursor: pointer; font-weight: 500; text-decoration: none; } + .link:hover { text-decoration: underline; } + .badge { padding: 2px 8px; border-radius: 10px; font-size: 0.8rem; font-weight: 500; } + .badge.on { background: #e8fde8; color: #27ae60; } + .badge.off { background: #fde8e8; color: #c0392b; } + .action-cell { display: flex; gap: 4px; } + code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 0.85rem; } + uui-table { width: 100%; } + + /* ── Form editor grid ── */ + .form-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 12px; + margin-bottom: 20px; padding: 16px; + background: var(--uui-color-surface-alt, #f9f9f9); border-radius: 6px; + } + .form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; font-weight: 500; } + .check-label { flex-direction: row !important; align-items: center; gap: 8px !important; } + + /* ── Section headers ── */ + .section-header { display: flex; justify-content: space-between; align-items: center; margin: 16px 0 8px; } + .section-header h3 { margin: 0; } + + /* ── Field cards ── */ + .field-card { + border: 1px solid var(--uui-color-border, #ddd); border-radius: 6px; + overflow: hidden; margin-bottom: 10px; + } + .field-card.field-hidden { + opacity: 0.5; border-style: dashed; + } + .field-header { + display: flex; align-items: center; gap: 8px; padding: 10px 14px; + background: var(--uui-color-surface-alt, #f4f4f4); flex-wrap: wrap; + } + .field-num { font-weight: 600; color: #888; min-width: 30px; } + .field-header select, .field-body select { + padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; + font-size: 0.9rem; background: #fff; height: 32px; box-sizing: border-box; + } + .field-actions { margin-left: auto; display: flex; gap: 4px; } + + /* ── Type picker button ── */ + .type-picker-btn { + padding: 4px 12px; border: 1px solid #ccc; border-radius: 4px; + font-size: 0.9rem; background: #fff; height: 32px; box-sizing: border-box; + cursor: pointer; display: flex; align-items: center; gap: 6px; + transition: border-color 0.2s; + } + .type-picker-btn:hover { border-color: #888; } + + /* ── Type picker dialog ── */ + .type-picker-dialog { + background: var(--uui-color-surface, #fff); border-radius: 8px; + width: 400px; max-width: 90vw; height: 480px; display: flex; flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + } + .type-picker-header { + display: flex; justify-content: space-between; align-items: center; + padding: 14px 18px; border-bottom: 1px solid #e0e0e0; + } + .type-picker-header h3 { margin: 0; font-size: 1rem; } + .type-picker-search { padding: 12px 18px; border-bottom: 1px solid #f0f0f0; } + .type-picker-search uui-input { width: 100%; } + .type-picker-list { overflow-y: auto; flex: 1; padding: 6px 0; } + .type-picker-option { + display: flex; align-items: center; justify-content: space-between; + width: 100%; padding: 7px 18px; border: none; background: none; + cursor: pointer; font-size: 0.85rem; text-align: left; + transition: background 0.1s; + } + .type-picker-option:hover { background: var(--uui-color-surface-alt, #f4f4f4); } + .type-picker-option.active { + background: var(--uui-color-surface-alt, #f3f3f5); + font-weight: 600; + border-left: 3px solid var(--uui-color-interactive, #1b264f); + } + .type-picker-label { flex: 1; } + .type-picker-type { color: #999; font-size: 0.8rem; font-family: monospace; } + .type-picker-empty { padding: 20px; text-align: center; color: #888; font-style: italic; } + .field-body { + display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 14px; + } + .field-body label { display: flex; flex-direction: column; gap: 4px; font-size: 0.8rem; font-weight: 500; min-width: 0; overflow: hidden; } + .field-body uui-input { width: 100%; min-width: 0; } + .field-body select { width: 100%; } + + /* ── Options (select/radio/checkbox) ── */ + .options-section { padding: 0 14px 14px; } + + /* ── Type-specific attributes ── */ + .field-attrs { + display: flex; gap: 10px; padding: 8px 14px; + background: var(--uui-color-surface-alt, #fafafa); + border-top: 1px solid #eee; flex-wrap: wrap; + } + .field-attrs label { + display: flex; flex-direction: column; gap: 4px; + font-size: 0.8rem; font-weight: 500; flex: 1; min-width: 120px; + } + .field-attrs uui-input { width: 100%; } + .div-content-label { grid-column: 1 / -1; } + .div-content-editor { + width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px; + font-family: monospace; font-size: 0.85rem; resize: vertical; + background: #fff; box-sizing: border-box; + } + + /* ── Settings panel ── */ + .settings-panel { + margin-bottom: 16px; padding: 16px; + background: var(--uui-color-surface-alt, #f0f4ff); + border: 1px solid #c8d6f0; border-radius: 6px; + } + .settings-header { margin-bottom: 12px; } + .settings-header h3 { margin: 0 0 4px; font-size: 1rem; } + .settings-hint { font-size: 0.8rem; color: #888; } + .settings-body { + display: flex; flex-wrap: wrap; gap: 10px 20px; + } + .settings-col-item { + min-width: 140px; padding: 4px 8px; + background: #fff; border: 1px solid #e0e0e0; border-radius: 4px; + cursor: grab; user-select: none; transition: box-shadow 0.15s, opacity 0.15s; + } + .settings-col-item:active { cursor: grabbing; } + .settings-col-item.dragging { opacity: 0.4; } + .settings-col-item.drag-over { box-shadow: inset 0 0 0 2px var(--uui-color-interactive, #1b264f); } + .drag-handle { color: #aaa; margin-right: 4px; font-size: 0.8rem; } + .option-row { display: flex; gap: 8px; margin-bottom: 6px; align-items: center; } + .option-row uui-input { flex: 1; } + + /* ── Embed info ── */ + .embed-render-row { + display: flex; align-items: center; gap: 12px; margin: 6px 0; + } + .embed-render-row code { + flex: 1; display: inline-block; padding: 6px 10px; + background: #fff; border: 1px solid #e0e0e0; border-radius: 4px; + font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + + /* ── Entries ── */ + .filter-bar { + display: flex; align-items: center; gap: 12px; margin-bottom: 16px; + padding: 10px 14px; background: var(--uui-color-surface-alt, #f9f9f9); + border-radius: 6px; flex-wrap: wrap; + } + .filter-search { flex: 1; min-width: 200px; } + .filter-dates { display: flex; align-items: center; gap: 10px; margin-left: auto; } + .filter-date-label { + display: flex; align-items: center; gap: 4px; font-size: 0.85rem; font-weight: 500; + } + .filter-date-label input[type="date"] { + padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; + font-size: 0.85rem; background: #fff; height: 32px; box-sizing: border-box; + } + .pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; } + .page-info { color: #888; font-size: 0.9rem; } + .cell-truncate { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .row-selected { background: var(--uui-color-surface-alt, #f0f4ff) !important; } + + /* ── Detail overlay ── */ + .overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); z-index: 9999; + display: flex; justify-content: center; align-items: center; + } + .detail-panel { + background: var(--uui-color-surface, #fff); border-radius: 8px; + width: 600px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + } + .detail-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid #e0e0e0; + } + .detail-header h3 { margin: 0; } + .detail-body { padding: 20px; overflow-y: auto; flex: 1; } + .detail-row { + display: flex; padding: 10px 0; border-bottom: 1px solid #f0f0f0; + } + .detail-label { font-weight: 600; min-width: 120px; color: #555; font-size: 0.9rem; } + .detail-value { flex: 1; word-break: break-word; white-space: pre-wrap; } + .detail-footer { padding: 12px 20px; border-top: 1px solid #e0e0e0; display: flex; justify-content: flex-end; } +`; diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/umbraco-package.json b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/umbraco-package.json new file mode 100644 index 0000000..54c40f5 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/umbraco-package.json @@ -0,0 +1,31 @@ +{ + "name": "uTPro.SimpleForm", + "version": "1.0.0", + "extensions": [ + { + "type": "dashboard", + "alias": "uTPro.SimpleForm.Dashboard", + "name": "Simple Form Builder", + "element": "/App_Plugins/simple-form/index.js", + "elementName": "utpro-simple-form-dashboard", + "weight": 15, + "meta": { + "label": "#simpleForm_title", + "pathname": "simple-form" + }, + "conditions": [ + { + "alias": "Umb.Condition.SectionAlias", + "match": "Umb.Section.Settings" + } + ] + }, + { + "type": "localization", + "alias": "uTPro.SimpleForm.Localize.EnUS", + "name": "Simple Form English", + "js": "/App_Plugins/simple-form/lang/en-us.js", + "meta": { "culture": "en-US" } + } + ] +} diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/detail-view.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/detail-view.js new file mode 100644 index 0000000..78c5d5d --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/detail-view.js @@ -0,0 +1,44 @@ +import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; + +/** + * Renders the entry detail overlay. + * @param {object} host - the dashboard element + */ +export function renderDetail(host) { + const s = host._detailEntry; + if (!s) return nothing; + + const isAdmin = host._permissions?.isAdmin; + const entries = Object.entries(s.data || {}); + + return html` +
{ if (e.target === e.currentTarget) host._closeDetail(); }}> +
+
+

Entry #${s.id}

+ host._closeDetail()}>✕ Close +
+
+
+ Date + ${new Date(s.createdUtc).toLocaleString()} +
+
+ IP Address + ${s.ipAddress || 'N/A'} +
+ ${entries.map(([k, v]) => html` +
+ ${k} + ${v || ''} +
+ `)} +
+ ${isAdmin ? html` + + ` : nothing} +
+
`; +} diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js new file mode 100644 index 0000000..dc31434 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js @@ -0,0 +1,342 @@ +import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; + +/** + * Renders the form editor view. + * @param {object} host - the dashboard element + */ +export function renderEditor(host) { + const f = host._editForm; + if (!f) return nothing; + + const needsOptions = (t) => ['select', 'radio', 'checkbox'].includes(t); + const showSettings = host._showColumnSettings; + + return html` + +
+ { host._view = 'list'; host._showColumnSettings = false; }}>← Back +

${f.id ? 'Edit' : 'New'} Form

+
+ ${f.id ? html` + host._viewEntries(f.id)}> + Entries (${host._entryCount ?? 0}) + + { host._showColumnSettings = !host._showColumnSettings; host.requestUpdate(); }}> + ⚙ Settings + + ` : nothing} + host._saveForm()}>Save Form +
+
+ + + ${showSettings && f.id ? html` + ${_renderEmbedSettings(host, f)} + ${_renderColumnSettings(host, f)} + ` : nothing} + + +
+ + + + + + + + +
+ + +
+

Fields

+ host._addField()}>+ Add Field +
+ ${f.fields.map((field, idx) => _renderFieldCard(host, field, idx, needsOptions))} +
+ ${host._typePickerIdx >= 0 ? _renderTypePicker(host) : nothing}`; +} + +/** + * Renders the Embed Code / API settings panel. + */ +function _renderEmbedSettings(host, f) { + return html` +
+
+

Embed API Settings

+
+
+ POST /api/utpro/simple-form/submit { "alias": "${f.alias}", "data": { ... } } +
+
+ { f.enableRenderApi = e.target.checked; host.requestUpdate(); }} + label=${f.enableRenderApi ? 'Enabled' : 'Disabled'}> + GET /api/utpro/simple-form/render/${f.alias} +
+
+ { f.enableEntriesApi = e.target.checked; host.requestUpdate(); }} + label=${f.enableEntriesApi ? 'Enabled' : 'Disabled'}> + GET /api/utpro/simple-form/entries/${f.alias} +
+
`; +} + +/** + * Renders the column visibility settings panel with drag & drop reordering. + */ +function _renderColumnSettings(host, f) { + const allFieldNames = f.fields.map(field => field.name).filter(n => n); + + // Build ordered list: visibleColumns first (in order), then unchecked ones + let orderedNames; + if (f.visibleColumns && f.visibleColumns.length > 0) { + const checked = f.visibleColumns.filter(n => allFieldNames.includes(n)); + const unchecked = allFieldNames.filter(n => !f.visibleColumns.includes(n)); + orderedNames = [...checked, ...unchecked]; + } else { + orderedNames = [...allFieldNames]; + } + + return html` +
+
+

⚙ Entries Column Settings

+ Select which fields to show as columns in the Entries view. Drag to reorder. +
+
+ ${allFieldNames.length === 0 ? html`
No fields yet. Add fields first.
` : nothing} + ${orderedNames.map((name, idx) => { + const isVisible = f.visibleColumns === null || f.visibleColumns === undefined + ? true + : f.visibleColumns.includes(name); + return html` + `; + })} +
+
`; +} + +/** + * Renders the field type picker dialog with search. + */ +function _renderTypePicker(host) { + const search = (host._typePickerSearch || '').toLowerCase(); + const filtered = host._fieldTypes.filter(ft => + ft.label.toLowerCase().includes(search) || ft.type.toLowerCase().includes(search) + ); + const idx = host._typePickerIdx; + const currentType = host._editForm?.fields[idx]?.type; + + return html` +
{ if (e.target === e.currentTarget) { host._typePickerIdx = -1; host.requestUpdate(); } }}> +
+
+

Select Field Type

+ { host._typePickerIdx = -1; host.requestUpdate(); }}>✕ +
+ +
+ ${filtered.map(ft => html` + + `)} + ${filtered.length === 0 ? html`
No matching types
` : nothing} +
+
+
`; +} + +/** + * Renders type-specific attribute fields. + * Uses field.attributes dict to store extra config per type. + */ +function _renderTypeAttributes(host, field, idx) { + const t = field.type; + if (!field.attributes) field.attributes = {}; + const attr = field.attributes; + const setAttr = (key, val) => { field.attributes[key] = val; host.requestUpdate(); }; + + // Number: min, max, step + if (t === 'number') return html` +
+ + + +
`; + + // Date: min, max + if (t === 'date') return html` +
+ + +
`; + + // Time: min, max + if (t === 'time') return html` +
+ + +
`; + + // Textarea: rows + if (t === 'textarea') return html` +
+ +
`; + + // File: accept, maxSize + if (t === 'file') return html` +
+ + +
`; + + // Range: min, max, step + if (t === 'range') return html` +
+ + + +
`; + + // Accept/Terms: text, linkUrl, linkText + if (t === 'accept') return html` +
+ + + +
`; + + // Step: title + if (t === 'step') return html` +
+ +
`; + + return nothing; +} + + +function _renderFieldCard(host, field, idx, needsOptions) { + const f = host._editForm; + const currentTypeLabel = host._fieldTypes.find(ft => ft.type === field.type)?.label || field.type; + + return html` +
+
+ #${idx + 1} + + host._updateField(idx, 'isHidden', !e.target.checked)} label=${field.isHidden ? 'Hidden' : 'Visible'}> + ${field.type !== 'div' && field.type !== 'step' ? html` + host._updateField(idx, 'required', e.target.checked)} label="Required"> + host._updateField(idx, 'isSensitive', e.target.checked)} label="Sensitive Data"> + ` : nothing} +
+ host._moveField(idx, -1)} ?disabled=${idx === 0}>▲ + host._moveField(idx, 1)} ?disabled=${idx === f.fields.length - 1}>▼ + host._removeField(idx)}>✕ +
+
+ ${field.type === 'div' || field.type === 'step' ? html` +
+ + +
+ ` : html` +
+ + + + + + + +
+ `} + ${_renderTypeAttributes(host, field, idx)} + ${needsOptions(field.type) ? html` +
+
Options + host._addOption(idx)}>+ Option +
+ ${(field.options || []).map((opt, oIdx) => html` +
+ { opt.text = e.target.value; host.requestUpdate(); }}> + { opt.value = e.target.value; host.requestUpdate(); }}> + host._removeOption(idx, oIdx)}>✕ +
`)} +
` : nothing} +
`; +} diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/entries-view.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/entries-view.js new file mode 100644 index 0000000..a298c69 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/entries-view.js @@ -0,0 +1,100 @@ +import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; + +/** + * Renders the entries list view with search, date filter, and checkboxes. + * @param {object} host - the dashboard element + */ +export function renderEntries(host) { + const isAdmin = host._permissions?.isAdmin; + const form = host._forms.find(f => f.id === host._viewFormId); + const formName = form?.name || 'Form'; + const pages = Math.max(1, Math.ceil(host._entryTotal / 20)); + const page = Math.floor(host._entrySkip / 20) + 1; + const allDataKeys = [...new Set(host._entries.flatMap(s => Object.keys(s.data || {})))]; + const visibleCols = form?.visibleColumns; + const allKeys = visibleCols && visibleCols.length > 0 + ? allDataKeys.filter(k => visibleCols.includes(k)) + : allDataKeys; + const allSelected = host._entries.length > 0 && host._selectedEntries.length === host._entries.length; + + return html` + + +
+ { host._view = 'list'; host._selectedEntries = []; }}>← Back +

Entries: ${formName}

+
+ ${host._entryTotal} entries + ${host._entries.length ? html` + host._exportCsv()}>Export CSV + ` : nothing} + ${isAdmin && host._selectedEntries.length ? html` + host._bulkDelete()}> + Delete (${host._selectedEntries.length}) + + ` : nothing} +
+
+ + +
+ { host._search = e.target.value; }} + @keydown=${(e) => { if (e.key === 'Enter') { host._entrySkip = 0; host._selectedEntries = []; host._loadEntries(); } }}> + +
+ + + { host._search = ''; host._dateFrom = ''; host._dateTo = ''; host._entrySkip = 0; host._selectedEntries = []; host._loadEntries(); }}>Clear +
+
+ + + ${!host._entries.length ? html`
No entries yet
` : html` + + + + host._toggleSelectAll()} /> + + Date + IP + ${allKeys.map(k => html`${k}`)} + Actions + + ${host._entries.map(s => html` + + + host._toggleEntrySelect(s.id)} /> + + ${new Date(s.createdUtc).toLocaleString()} + ${s.ipAddress || ''} + ${allKeys.map(k => html`${s.data?.[k] || ''}`)} + + host._viewDetail(s)} title="View">☰ + ${isAdmin ? html` + host._deleteEntry(s.id)} title="Delete">✕ + ` : nothing} + + `)} + + ${host._entryTotal > 20 ? html` + ` : nothing} + `} +
`; +} diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/list-view.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/list-view.js new file mode 100644 index 0000000..81da4cf --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/list-view.js @@ -0,0 +1,53 @@ +import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; + +/** + * Renders the form list view. + * - Admin: can create, edit, delete forms + * - Non-admin: can only view list and access entries + * @param {object} host - the dashboard element + */ +export function renderList(host) { + const isAdmin = host._permissions?.isAdmin; + + return html` + +
+

Form Builder

+ ${isAdmin ? html` + host._newForm()}>+ New Form + ` : nothing} +
+ ${host._loading ? html`
` : nothing} + ${!host._forms.length && !host._loading ? html`
No forms yet.${isAdmin ? ' Create one!' : ''}
` : nothing} + ${host._forms.length ? html` + + + Name + Alias + Fields + Status + Actions + + ${host._forms.map(f => html` + + + ${isAdmin + ? html` host._editExisting(f.id)}>${f.name}` + : html`${f.name}`} + + ${f.alias} + ${f.fields?.length || 0} + ${f.isEnabled ? html`Active` : html`Disabled`} + + ${isAdmin ? html` + host._editExisting(f.id)}>Edit + ` : nothing} + host._viewEntries(f.id)}>Entries + ${isAdmin ? html` + host._deleteForm(f.id)}>Delete + ` : nothing} + + `)} + ` : nothing} +
`; +} diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/css/simple-form.css b/uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/css/simple-form.css new file mode 100644 index 0000000..3d4279b --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/css/simple-form.css @@ -0,0 +1,25 @@ +/* ── SimpleForm Frontend Styles ── */ +.sf { max-width: 100%; } +.sf .sf-error { color: #e53935; font-size: 0.8rem; display: none; } +.sf .sf-error:not(:empty) { display: block; } +.sf .sf-required { color: #e53935; } +.sf-actions { display: flex; gap: 12px; margin-top: 20px; } +.sf-btn { + padding: 14px 40px; font-size: 14px; font-weight: 700; letter-spacing: 2px; + text-transform: uppercase; border: 2px solid #555; border-radius: 4px; cursor: pointer; + transition: all 0.2s; +} +.sf-message { margin-top: 16px; padding: 12px; border-radius: 4px; } +.sf-success { background: #e8fde8; color: #27ae60; } +.sf-fail { background: #fde8e8; color: #c0392b; } + +/* ── Content Block ── */ +.sf-content-block { margin: 8px 0; } + +/* ── Step divider ── */ +.sf-step { margin: 16px 0 8px; } +.sf-step-title { font-size: 1.1rem; margin: 0 0 8px; } +.sf-step-divider { border: none; border-top: 1px solid #ddd; } + +/* ── Range ── */ +.sf-range-value { font-weight: 600; margin-left: 8px; } diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/js/simple-form.js b/uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/js/simple-form.js new file mode 100644 index 0000000..70dbd76 --- /dev/null +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/js/simple-form.js @@ -0,0 +1,93 @@ +/** + * SimpleForm — client-side validation & submission handler. + * Reads config from data attributes on the
element: + * data-alias — form alias (required) + * data-redirect — redirect URL after success (optional) + * data-btn-text — submit button text for reset after submit (optional) + */ +(function () { + document.querySelectorAll('form.sf').forEach(function (form) { + var alias = form.dataset.alias; + if (!alias) return; + + var redirectUrl = form.dataset.redirect || ''; + var btnText = form.dataset.btnText || 'Submit'; + + form.addEventListener('submit', async function (e) { + e.preventDefault(); + var msgEl = form.querySelector('.sf-message'); + var btn = form.querySelector('[type="submit"]'); + form.querySelectorAll('.sf-error').forEach(function (el) { el.textContent = ''; }); + if (msgEl) msgEl.style.display = 'none'; + + var data = {}; + var valid = true; + var inputs = form.querySelectorAll('[name]'); + inputs.forEach(function (input) { + if (input.name === '__alias') return; + var name = input.name; + if (input.type === 'checkbox') { + var checked = form.querySelectorAll('input[name="' + name + '"]:checked'); + if (checked.length > 0) data[name] = Array.from(checked).map(function (c) { return c.value; }).join(', '); + } else if (input.type === 'radio') { + var sel = form.querySelector('input[name="' + name + '"]:checked'); + if (sel) data[name] = sel.value; + } else if (input.type !== 'file') { + data[name] = input.value; + } + if (input.hasAttribute('required') && !data[name]) { + var errEl = form.querySelector('.sf-error[data-for="' + name + '"]'); + if (errEl) errEl.textContent = input.dataset.msg || 'Required'; + valid = false; + } + if (input.pattern && data[name]) { + if (!new RegExp(input.pattern).test(data[name])) { + var errEl2 = form.querySelector('.sf-error[data-for="' + name + '"]'); + if (errEl2) errEl2.textContent = input.dataset.msg || 'Invalid'; + valid = false; + } + } + }); + + if (window.__sfBeforeSubmit) { + var hookResult = await window.__sfBeforeSubmit(alias, data, form); + if (hookResult === false) return; + if (typeof hookResult === 'object') Object.assign(data, hookResult); + } + + if (!valid) return; + if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; } + try { + var resp = await fetch('/api/utpro/simple-form/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ alias: alias, data: data }) + }); + var result = await resp.json(); + if (resp.ok) { + if (redirectUrl) { window.location.href = redirectUrl; return; } + if (msgEl) { + msgEl.className = 'sf-message sf-success'; + msgEl.textContent = result.message || 'Thank you!'; + msgEl.style.display = 'block'; + } + form.reset(); + if (window.__sfAfterSubmit) window.__sfAfterSubmit(alias, true, result); + } else { + if (msgEl) { + msgEl.className = 'sf-message sf-fail'; + msgEl.textContent = result.message || 'Error'; + msgEl.style.display = 'block'; + } + } + } catch (err) { + if (msgEl) { + msgEl.className = 'sf-message sf-fail'; + msgEl.textContent = 'Network error'; + msgEl.style.display = 'block'; + } + } + if (btn) { btn.disabled = false; btn.textContent = btnText; } + }); + }); +})(); From e8a4efab2c2f270426df706f5306dabc94cf9b0e Mon Sep 17 00:00:00 2001 From: Nguyen Thien Tu <15050790+thientu995@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:21:44 +0700 Subject: [PATCH 4/9] Add group --- .../Migrations/SimpleFormMigration.cs | 163 ++++- .../Models/FormModels.cs | 31 + .../Services/SimpleFormService.cs | 16 +- .../Views/Partials/SimpleForm/Default.cshtml | 78 ++- .../Views/_ViewImports.cshtml | 4 - .../wwwroot/App_Plugins/simple-form/index.js | 156 ++++- .../wwwroot/App_Plugins/simple-form/styles.js | 126 ++++ .../simple-form/views/editor-view.js | 603 ++++++++++-------- .../uTPro/simple-form/css/simple-form.css | 6 + .../Views/Partials/SimpleForm/Default.cshtml | 78 ++- .../wwwroot/App_Plugins/simple-form/index.js | 156 ++++- .../wwwroot/App_Plugins/simple-form/styles.js | 126 ++++ .../simple-form/views/editor-view.js | 603 ++++++++++-------- .../uTPro/simple-form/css/simple-form.css | 6 + 14 files changed, 1493 insertions(+), 659 deletions(-) delete mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Views/_ViewImports.cshtml diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs index d980299..369fa25 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs @@ -94,6 +94,159 @@ INSERT INTO utpro_SimpleForm } } +// ── v2: Add GroupsJson column ── + +public class AddGroupsJsonColumnV2 : MigrationBase +{ + public AddGroupsJsonColumnV2(IMigrationContext context) : base(context) { } + + protected override void Migrate() + { + if (!ColumnExists("utpro_SimpleForm", "GroupsJson")) + { + Alter.Table("utpro_SimpleForm") + .AddColumn("GroupsJson").AsCustom("NTEXT").Nullable() + .Do(); + } + } +} + +// ── v2: Migrate existing FieldsJson into GroupsJson ── + +public class MigrateFieldsToGroupsV2 : MigrationBase +{ + public MigrateFieldsToGroupsV2(IMigrationContext context) : base(context) { } + + protected override void Migrate() + { + // Find all forms that have fields but no groups yet + // NTEXT columns cannot use = or <> operators directly, so use DATALENGTH / IS NULL checks + var rows = Context.Database.Fetch( + "SELECT Id, CAST(FieldsJson AS NVARCHAR(MAX)) AS FieldsJson FROM utpro_SimpleForm WHERE FieldsJson IS NOT NULL AND DATALENGTH(FieldsJson) > 4 AND (GroupsJson IS NULL OR DATALENGTH(GroupsJson) <= 4)"); + + foreach (var row in rows) + { + int id = (int)row.Id; + string fieldsJson = (string)row.FieldsJson; + + // Skip if fieldsJson is empty array + if (string.IsNullOrWhiteSpace(fieldsJson) || fieldsJson.Trim() == "[]") + continue; + + // Wrap existing fields into a single default group with 1 column (width=12) + var groupsJson = @"[{""id"":""g_default"",""name"":"""",""cssClass"":"""",""columns"":[{""id"":""c_default"",""width"":12,""fields"":" + fieldsJson + @"}],""sortOrder"":0}]"; + + Context.Database.Execute( + "UPDATE utpro_SimpleForm SET GroupsJson = CAST(@0 AS NTEXT), FieldsJson = CAST('[]' AS NTEXT) WHERE Id = @1", + groupsJson, id); + } + } +} + +// ── v3: Convert old flat group.fields format to new group.columns[].fields[] format ── + +public class ConvertGroupsToColumnFormatV3 : MigrationBase +{ + public ConvertGroupsToColumnFormatV3(IMigrationContext context) : base(context) { } + + protected override void Migrate() + { + // Find forms that have GroupsJson with old format (has "fields" at group level instead of "columns") + var rows = Context.Database.Fetch( + "SELECT Id, CAST(GroupsJson AS NVARCHAR(MAX)) AS GroupsJson FROM utpro_SimpleForm WHERE GroupsJson IS NOT NULL AND DATALENGTH(GroupsJson) > 4"); + + foreach (var row in rows) + { + int id = (int)row.Id; + string json = (string)row.GroupsJson; + if (string.IsNullOrWhiteSpace(json) || json.Trim() == "[]") continue; + + // Check if it's old format: contains "fields" at group level but no "columns" array with objects + // Old format: [{"fields":[...],"columns":1,...}] + // New format: [{"columns":[{"width":12,"fields":[...]}],...}] + // Simple heuristic: if JSON contains "\"columns\":" followed by a number, it's old format + if (!json.Contains("\"columns\":[{")) // new format has "columns":[{...}] + { + try + { + var opts = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }; + var oldGroups = System.Text.Json.JsonSerializer.Deserialize(json); + var newGroups = new System.Collections.Generic.List(); + + foreach (var g in oldGroups.EnumerateArray()) + { + var gId = g.TryGetProperty("id", out var idProp) ? idProp.GetString() : System.Guid.NewGuid().ToString("N"); + var gName = g.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : ""; + var gCss = g.TryGetProperty("cssClass", out var cssProp) ? cssProp.GetString() : ""; + var gSort = g.TryGetProperty("sortOrder", out var sortProp) ? sortProp.GetInt32() : 0; + + // Get old fields array + var fieldsJson = "[]"; + if (g.TryGetProperty("fields", out var fieldsProp) && fieldsProp.ValueKind == System.Text.Json.JsonValueKind.Array) + { + fieldsJson = fieldsProp.GetRawText(); + } + + newGroups.Add(new + { + id = gId, + name = gName, + cssClass = gCss, + columns = new[] { new { id = "c_" + gId, width = 12, fields = System.Text.Json.JsonSerializer.Deserialize(fieldsJson) } }, + sortOrder = gSort + }); + } + + var newJson = System.Text.Json.JsonSerializer.Serialize(newGroups, opts); + Context.Database.Execute( + "UPDATE utpro_SimpleForm SET GroupsJson = CAST(@0 AS NTEXT) WHERE Id = @1", + newJson, id); + } + catch + { + // Skip if JSON parsing fails — don't break migration + } + } + } + } +} + +// ── v4: Update default Contact Us form to use proper 2-group layout ── + +public class UpdateContactFormLayoutV4 : MigrationBase +{ + public UpdateContactFormLayoutV4(IMigrationContext context) : base(context) { } + + protected override void Migrate() + { + var groupsJson = @"[ + { + ""id"":""g1"",""name"":"""",""cssClass"":"""",""sortOrder"":0, + ""columns"":[ + {""id"":""g1c1"",""width"":6,""fields"":[ + {""id"":""f1"",""type"":""text"",""label"":""Name"",""name"":""name"",""placeholder"":""Name"",""required"":true,""sortOrder"":0,""validationMessage"":""Please enter your name""} + ]}, + {""id"":""g1c2"",""width"":6,""fields"":[ + {""id"":""f2"",""type"":""email"",""label"":""Email"",""name"":""email"",""placeholder"":""Email"",""required"":true,""sortOrder"":0,""validationMessage"":""Please enter a valid email""} + ]} + ] + }, + { + ""id"":""g2"",""name"":"""",""cssClass"":"""",""sortOrder"":1, + ""columns"":[ + {""id"":""g2c1"",""width"":12,""fields"":[ + {""id"":""f3"",""type"":""textarea"",""label"":""Message"",""name"":""message"",""placeholder"":""Message"",""required"":true,""sortOrder"":0,""validationMessage"":""Please enter your message""} + ]} + ] + } +]"; + + Context.Database.Execute( + "UPDATE utpro_SimpleForm SET GroupsJson = CAST(@0 AS NTEXT), FieldsJson = CAST('[]' AS NTEXT) WHERE Alias = @1", + groupsJson, "contact-us"); + } +} + // ── Migration runner ── public class RunSimpleFormMigration : IComposer @@ -132,7 +285,15 @@ public Task HandleAsync(Umbraco.Cms.Core.Notifications.UmbracoApplicationStarted var plan = new MigrationPlan("uTPro.SimpleForm"); plan.From(string.Empty) .To("simpleform-v1-001-tables") - .To("simpleform-v1-002-seed"); + .To("simpleform-v1-002-seed") + .To("simpleform-v2-001-groups") + .To("simpleform-v2-003-migrate-fields-fix") + .To("simpleform-v3-001-column-format") + .To("simpleform-v4-001-contact-layout"); + + // Handle failed v2-002 state + plan.From("simpleform-v2-002-migrate-fields") + .To("simpleform-v2-003-migrate-fields-fix"); var upgrader = new Upgrader(plan); upgrader.Execute(_migrationPlanExecutor, _coreScopeProvider, _keyValueService); diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs index 1a48e6c..db42b96 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs @@ -13,6 +13,7 @@ public class SimpleFormDto public string Name { get; set; } = string.Empty; public string Alias { get; set; } = string.Empty; public string? FieldsJson { get; set; } + public string? GroupsJson { get; set; } public string? SuccessMessage { get; set; } public string? RedirectUrl { get; set; } public string? EmailTo { get; set; } @@ -46,6 +47,8 @@ public class FormViewModel public string Name { get; set; } = string.Empty; public string Alias { get; set; } = string.Empty; public List Fields { get; set; } = []; + /// Groups organise fields into visual sections, each with its own grid layout (1-12 columns). + public List Groups { get; set; } = []; public string? SuccessMessage { get; set; } public string? RedirectUrl { get; set; } public string? EmailTo { get; set; } @@ -59,6 +62,33 @@ public class FormViewModel public DateTime UpdatedUtc { get; set; } } +/// +/// A visual group/fieldset that contains columns, each with its own width and fields. +/// +public class FormGroupViewModel +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + /// Optional group title (rendered as fieldset legend or heading). + public string? Name { get; set; } + /// Optional CSS class applied to the group wrapper. + public string? CssClass { get; set; } + /// Columns in this group. Each column has a width (1-12) and its own fields. + public List Columns { get; set; } = []; + public int SortOrder { get; set; } +} + +/// +/// A single column within a group. Width is based on a 12-column grid. +/// +public class FormColumnViewModel +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + /// Column width in a 12-column grid (1-12). + public int Width { get; set; } = 12; + /// Fields in this column, ordered by SortOrder. + public List Fields { get; set; } = []; +} + public class FormFieldViewModel { public string Id { get; set; } = Guid.NewGuid().ToString("N"); @@ -94,6 +124,7 @@ public class SaveFormRequest public string Name { get; set; } = string.Empty; public string Alias { get; set; } = string.Empty; public List Fields { get; set; } = []; + public List Groups { get; set; } = []; public string? SuccessMessage { get; set; } public string? RedirectUrl { get; set; } public string? EmailTo { get; set; } diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs index f2a932d..aae4ce2 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Services/SimpleFormService.cs @@ -67,6 +67,7 @@ public List GetAllForms() var db = scope.Database; var now = DateTime.UtcNow; var fieldsJson = JsonSerializer.Serialize(request.Fields, JsonOpts); + var groupsJson = JsonSerializer.Serialize(request.Groups, JsonOpts); if (request.Id > 0) { @@ -76,6 +77,7 @@ public List GetAllForms() existing.Name = request.Name; existing.Alias = request.Alias; existing.FieldsJson = fieldsJson; + existing.GroupsJson = groupsJson; existing.SuccessMessage = request.SuccessMessage; existing.RedirectUrl = request.RedirectUrl; existing.EmailTo = request.EmailTo; @@ -100,6 +102,7 @@ public List GetAllForms() Name = request.Name, Alias = request.Alias, FieldsJson = fieldsJson, + GroupsJson = groupsJson, SuccessMessage = request.SuccessMessage, RedirectUrl = request.RedirectUrl, EmailTo = request.EmailTo, @@ -152,14 +155,21 @@ public List GetAllForms() var fields = string.IsNullOrEmpty(form.FieldsJson) ? [] : JsonSerializer.Deserialize>(form.FieldsJson, JsonOpts) ?? []; - foreach (var f in fields.Where(f => f.Required && !f.IsHidden)) + // Collect fields from groups → columns → fields + var groups = string.IsNullOrEmpty(form.GroupsJson) + ? [] : JsonSerializer.Deserialize>(form.GroupsJson, JsonOpts) ?? []; + var allFields = groups.SelectMany(g => g.Columns.SelectMany(c => c.Fields)).ToList(); + // Include any legacy ungrouped fields for backward compatibility + allFields.AddRange(fields); + + foreach (var f in allFields.Where(f => f.Required && !f.IsHidden)) { if (!data.TryGetValue(f.Name, out var val) || string.IsNullOrWhiteSpace(val)) return (false, $"Field '{f.Label}' is required"); } // Encrypt sensitive fields - var sensitiveNames = fields + var sensitiveNames = allFields .Where(f => f.IsSensitive || f.Type == "password") .Select(f => f.Name) .ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -241,6 +251,8 @@ public PagedResult GetEntries(int formId, int skip, int take, bo Id = dto.Id, Name = dto.Name, Alias = dto.Alias, Fields = string.IsNullOrEmpty(dto.FieldsJson) ? [] : JsonSerializer.Deserialize>(dto.FieldsJson, JsonOpts) ?? [], + Groups = string.IsNullOrEmpty(dto.GroupsJson) + ? [] : JsonSerializer.Deserialize>(dto.GroupsJson, JsonOpts) ?? [], SuccessMessage = dto.SuccessMessage, RedirectUrl = dto.RedirectUrl, EmailTo = dto.EmailTo, EmailSubject = dto.EmailSubject, StoreEntries = dto.StoreEntries, IsEnabled = dto.IsEnabled, diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Default.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Default.cshtml index 6f140db..2569a56 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Default.cshtml +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/Partials/SimpleForm/Default.cshtml @@ -1,3 +1,4 @@ +@using Microsoft.Extensions.DependencyInjection @using uTPro.Extension.CurrentSite @model uTPro.Feature.SimpleForm.Models.FormViewModel @inject ICurrentSiteExtension CurrentSite @@ -9,48 +10,61 @@ var resetText = ViewBag.ResetBtnText as string ?? "RESET"; } - -
- @foreach (var field in Model.Fields.OrderBy(f => f.SortOrder)) - { - if (field.IsHidden) { continue; } - - var fieldCss = field.CssClass ?? ""; - -
- @{ - var customPartial = $"~/Views/Partials/SimpleForm/Fields/{field.Type}.cshtml"; - var fallbackPartial = "~/Views/Partials/SimpleForm/Fields/_Default.cshtml"; - var viewEngine = ViewContext.HttpContext.RequestServices.GetRequiredService(); - var viewExists = viewEngine.GetView(null, customPartial, false).Success - || viewEngine.FindView(ViewContext, customPartial, false).Success; - } - @if (viewExists) + @foreach (var group in (Model.Groups ?? []).OrderBy(g => g.SortOrder)) + { + var groupCss = group.CssClass ?? ""; +
+ @if (!string.IsNullOrWhiteSpace(group.Name)) + { + @group.Name + } +
+ @foreach (var col in group.Columns) { - @await Html.PartialAsync(customPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) - } - else - { - @await Html.PartialAsync(fallbackPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) + var colWidth = Math.Clamp(col.Width, 1, 12); +
+ @foreach (var field in col.Fields.OrderBy(f => f.SortOrder)) + { + if (field.IsHidden) { continue; } + var fieldCss = field.CssClass ?? ""; +
+ @{ + var customPartial = $"~/Views/Partials/SimpleForm/Fields/{field.Type}.cshtml"; + var fallbackPartial = "~/Views/Partials/SimpleForm/Fields/_Default.cshtml"; + var viewEngine = ViewContext.HttpContext.RequestServices.GetRequiredService(); + var viewExists = viewEngine.GetView(null, customPartial, false).Success + || viewEngine.FindView(ViewContext, customPartial, false).Success; + } + @if (viewExists) + { + @await Html.PartialAsync(customPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) + } + else + { + @await Html.PartialAsync(fallbackPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) + } +
+ } +
}
- } +
+ } -
-
    -
  • - @if (showReset) - { -
  • - } -
-
+
+
    +
  • + @if (showReset) + { +
  • + } +
diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/_ViewImports.cshtml b/uTPro/Feature/uTPro.Feature.SimpleForm/Views/_ViewImports.cshtml deleted file mode 100644 index 4028d16..0000000 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/Views/_ViewImports.cshtml +++ /dev/null @@ -1,4 +0,0 @@ -@using Microsoft.AspNetCore.Mvc -@using Microsoft.AspNetCore.Mvc.ViewFeatures -@using Microsoft.Extensions.DependencyInjection -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/index.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/index.js index aada3ba..2459f7e 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/index.js +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/index.js @@ -43,6 +43,9 @@ export class UtproSimpleFormDashboard extends UmbLitElement { _entryCount: { type: Number, state: true }, _typePickerIdx: { type: Number, state: true }, _typePickerSearch: { type: String, state: true }, + _typePickerGroupIdx: { type: Number, state: true }, + _typePickerColIdx: { type: Number, state: true }, + _fieldSettingsLoc: { type: Object, state: true }, }; // ── Styles ── @@ -73,6 +76,9 @@ export class UtproSimpleFormDashboard extends UmbLitElement { this._entryCount = 0; this._typePickerIdx = -1; this._typePickerSearch = ''; + this._typePickerGroupIdx = -1; + this._typePickerColIdx = -1; + this._fieldSettingsLoc = null; this.consumeContext(UMB_AUTH_CONTEXT, (ctx) => { this.#authContext = ctx; }); } @@ -118,7 +124,7 @@ export class UtproSimpleFormDashboard extends UmbLitElement { // ── Form CRUD ── _newForm() { this._editForm = { - id: 0, name: '', alias: '', fields: [], + id: 0, name: '', alias: '', fields: [], groups: [], successMessage: 'Thank you!', redirectUrl: '', emailTo: '', emailSubject: '', storeEntries: true, isEnabled: true }; @@ -158,55 +164,159 @@ export class UtproSimpleFormDashboard extends UmbLitElement { } catch (e) { this._msg(e.message, true); } } - // ── Field management ── - _addField() { + // ── Group management ── + _addGroup() { const f = this._editForm; - const idx = f.fields.length; - f.fields = [...f.fields, { + if (!f.groups) f.groups = []; + const idx = f.groups.length; + f.groups = [...f.groups, { id: crypto.randomUUID?.() || Date.now().toString(36), - type: 'text', label: '', name: 'field_' + idx, + name: '', + cssClass: '', + columns: [{ id: crypto.randomUUID?.() || Date.now().toString(36), width: 12, fields: [] }], + sortOrder: idx + }]; + this.requestUpdate(); + } + + _removeGroup(gIdx) { + if (!confirm('Remove this group and all its columns/fields?')) return; + this._editForm.groups = this._editForm.groups.filter((_, i) => i !== gIdx); + this._editForm.groups.forEach((g, i) => g.sortOrder = i); + this.requestUpdate(); + } + + _moveGroup(gIdx, dir) { + const arr = [...this._editForm.groups]; + const newIdx = gIdx + dir; + if (newIdx < 0 || newIdx >= arr.length) return; + [arr[gIdx], arr[newIdx]] = [arr[newIdx], arr[gIdx]]; + arr.forEach((g, i) => g.sortOrder = i); + this._editForm.groups = arr; + this.requestUpdate(); + } + + _updateGroup(gIdx, key, val) { + this._editForm.groups[gIdx][key] = val; + this.requestUpdate(); + } + + // ── Column management within a group ── + _addColumn(gIdx) { + const g = this._editForm.groups[gIdx]; + if (!g.columns) g.columns = []; + g.columns = [...g.columns, { + id: crypto.randomUUID?.() || Date.now().toString(36), + width: 6, + fields: [] + }]; + this.requestUpdate(); + } + + _removeColumn(gIdx, cIdx) { + if (!confirm('Remove this column and all its fields?')) return; + this._editForm.groups[gIdx].columns = this._editForm.groups[gIdx].columns.filter((_, i) => i !== cIdx); + this.requestUpdate(); + } + + _updateColumnWidth(gIdx, cIdx, val) { + this._editForm.groups[gIdx].columns[cIdx].width = Math.min(12, Math.max(1, parseInt(val) || 1)); + this.requestUpdate(); + } + + /** + * Move an entire column (with all its fields) to another group. + * @param {number} fromGIdx - source group index + * @param {number} cIdx - column index within source group + * @param {number} toGIdx - destination group index + */ + _moveColumnTo(fromGIdx, cIdx, toGIdx) { + const f = this._editForm; + const col = f.groups[fromGIdx].columns.splice(cIdx, 1)[0]; + if (!col) return; + f.groups[toGIdx].columns.push(col); + this.requestUpdate(); + } + + _swapColumn(gIdx, cIdx, dir) { + const cols = this._editForm.groups[gIdx].columns; + const newIdx = cIdx + dir; + if (newIdx < 0 || newIdx >= cols.length) return; + [cols[cIdx], cols[newIdx]] = [cols[newIdx], cols[cIdx]]; + this.requestUpdate(); + } + + // ── Field management within a column ── + _addFieldToColumn(gIdx, cIdx) { + const col = this._editForm.groups[gIdx].columns[cIdx]; + const idx = col.fields.length; + col.fields = [...col.fields, { + id: crypto.randomUUID?.() || Date.now().toString(36), + type: 'text', label: '', name: 'field_' + Date.now().toString(36), placeholder: '', cssClass: '', required: false, validation: '', validationMessage: '', defaultValue: '', - options: [], sortOrder: idx, colSpan: 1, attributes: {} + options: [], sortOrder: idx, attributes: {} }]; this.requestUpdate(); } - _removeField(idx) { - const removedName = this._editForm.fields[idx]?.name; - this._editForm.fields = this._editForm.fields.filter((_, i) => i !== idx); + _removeFieldFromColumn(gIdx, cIdx, fIdx) { + const removedName = this._editForm.groups[gIdx].columns[cIdx].fields[fIdx]?.name; + this._editForm.groups[gIdx].columns[cIdx].fields = this._editForm.groups[gIdx].columns[cIdx].fields.filter((_, i) => i !== fIdx); if (removedName && this._editForm.visibleColumns) { this._editForm.visibleColumns = this._editForm.visibleColumns.filter(c => c !== removedName); } this.requestUpdate(); } - _moveField(idx, dir) { - const arr = [...this._editForm.fields]; - const newIdx = idx + dir; + _moveFieldInColumn(gIdx, cIdx, fIdx, dir) { + const arr = [...this._editForm.groups[gIdx].columns[cIdx].fields]; + const newIdx = fIdx + dir; if (newIdx < 0 || newIdx >= arr.length) return; - [arr[idx], arr[newIdx]] = [arr[newIdx], arr[idx]]; + [arr[fIdx], arr[newIdx]] = [arr[newIdx], arr[fIdx]]; arr.forEach((f, i) => f.sortOrder = i); - this._editForm.fields = arr; + this._editForm.groups[gIdx].columns[cIdx].fields = arr; this.requestUpdate(); } - _updateField(idx, key, val) { - this._editForm.fields[idx][key] = val; + _updateFieldInColumn(gIdx, cIdx, fIdx, key, val) { + this._editForm.groups[gIdx].columns[cIdx].fields[fIdx][key] = val; if (key === 'type' && val === 'password') { - this._editForm.fields[idx].isSensitive = true; + this._editForm.groups[gIdx].columns[cIdx].fields[fIdx].isSensitive = true; } this.requestUpdate(); } - _addOption(idx) { - if (!this._editForm.fields[idx].options) this._editForm.fields[idx].options = []; - this._editForm.fields[idx].options.push({ text: '', value: '' }); + _addOptionInColumn(gIdx, cIdx, fIdx) { + if (!this._editForm.groups[gIdx].columns[cIdx].fields[fIdx].options) + this._editForm.groups[gIdx].columns[cIdx].fields[fIdx].options = []; + this._editForm.groups[gIdx].columns[cIdx].fields[fIdx].options.push({ text: '', value: '' }); + this.requestUpdate(); + } + + _removeOptionInColumn(gIdx, cIdx, fIdx, oIdx) { + this._editForm.groups[gIdx].columns[cIdx].fields[fIdx].options.splice(oIdx, 1); this.requestUpdate(); } - _removeOption(fIdx, oIdx) { - this._editForm.fields[fIdx].options.splice(oIdx, 1); + // ── Move field between groups/columns ── + /** + * Move a field from one location to another. + * @param {object} from - { gIdx, cIdx, fIdx } source (-1 for ungrouped) + * @param {object} to - { gIdx, cIdx } destination (-1 for ungrouped) + */ + _moveFieldTo(from, to) { + const f = this._editForm; + + // Remove from source column + const field = f.groups[from.gIdx].columns[from.cIdx].fields.splice(from.fIdx, 1)[0]; + if (!field) return; + + // Add to destination column + const destCol = f.groups[to.gIdx].columns[to.cIdx]; + field.sortOrder = destCol.fields.length; + destCol.fields.push(field); + this.requestUpdate(); } diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/styles.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/styles.js index 8c50c36..7e733fc 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/styles.js +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/styles.js @@ -185,6 +185,7 @@ export const dashboardStyles = css` background: rgba(0,0,0,0.5); z-index: 9999; display: flex; justify-content: center; align-items: center; } + .overlay.overlay-top { z-index: 10001; } .detail-panel { background: var(--uui-color-surface, #fff); border-radius: 8px; width: 600px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column; @@ -202,4 +203,129 @@ export const dashboardStyles = css` .detail-label { font-weight: 600; min-width: 120px; color: #555; font-size: 0.9rem; } .detail-value { flex: 1; word-break: break-word; white-space: pre-wrap; } .detail-footer { padding: 12px 20px; border-top: 1px solid #e0e0e0; display: flex; justify-content: flex-end; } + + /* ── Group cards ── */ + .group-card { + border: 2px solid var(--uui-color-border, #ccc); border-radius: 8px; + margin-bottom: 16px; overflow: hidden; + background: var(--uui-color-surface, #fff); + } + .group-header { + display: flex; align-items: center; gap: 12px; padding: 12px 16px; + background: var(--uui-color-surface-alt, #f0f0f5); flex-wrap: wrap; + } + .group-num { font-weight: 700; font-size: 1rem; color: #555; min-width: 80px; } + .group-settings { display: flex; gap: 10px; flex: 1; flex-wrap: wrap; align-items: flex-end; } + .group-setting-label { + display: flex; flex-direction: column; gap: 3px; + font-size: 0.8rem; font-weight: 500; min-width: 100px; + } + .group-setting-label uui-input { width: 100%; min-width: 80px; } + .group-actions { display: flex; gap: 4px; margin-left: auto; } + .group-preview { + padding: 10px 16px; border-bottom: 1px solid #eee; + background: var(--uui-color-surface-alt, #fafafa); + } + .group-preview-label { font-size: 0.8rem; color: #666; margin-bottom: 6px; display: block; } + .group-col-preview { min-height: 32px; } + .group-col-cell { + background: #e0e0e0; border-radius: 4px; padding: 6px 10px; + text-align: center; font-size: 0.8rem; font-weight: 600; color: #555; + } + .group-grid-empty { + grid-column: 1 / -1; text-align: center; color: #aaa; + font-size: 0.8rem; font-style: italic; padding: 4px; + } + .group-columns-container { + display: flex; gap: 12px; padding: 12px 16px; flex-wrap: wrap; + } + + /* ── Column cards within a group ── */ + .col-card { + min-width: 200px; box-sizing: border-box; + border: 1px dashed var(--uui-color-border, #ccc); border-radius: 6px; + background: var(--uui-color-surface-alt, #fafafa); + } + .col-header { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; + background: var(--uui-color-surface-alt, #f0f0f0); + border-bottom: 1px solid #e0e0e0; + } + .col-num { font-weight: 600; font-size: 0.85rem; color: #555; } + .col-width-label { + display: flex; align-items: center; gap: 4px; + font-size: 0.8rem; font-weight: 500; + } + .col-width-label uui-input { width: 55px; } + .col-actions { margin-left: auto; } + .col-fields { padding: 8px 10px; } + .col-add-field { + text-align: center; padding: 6px 0; margin-top: 4px; + border-top: 1px dashed #ddd; + } + + /* ── Move to select ── */ + .move-to-select { + padding: 3px 6px; border: 1px solid #ccc; border-radius: 4px; + font-size: 0.78rem; background: #fff; height: 33px; box-sizing: border-box; + cursor: pointer; color: #555; + } + .move-to-select:hover { border-color: #888; } + + /* ── Column drag & drop ── */ + .col-drag-handle { + cursor: grab; color: #aaa; font-size: 0.9rem; user-select: none; + } + .col-drag-handle:active { cursor: grabbing; } + .col-card.col-dragging { opacity: 0.4; } + .col-card.col-drag-over { + box-shadow: inset 0 0 0 2px var(--uui-color-interactive, #1b264f); + border-color: var(--uui-color-interactive, #1b264f); + } + + /* ── Compact field card ── */ + .fc { + display: flex; align-items: center; gap: 6px; padding: 6px 8px; + border: 1px solid #e0e0e0; border-radius: 4px; margin-bottom: 4px; + background: #fff; cursor: grab; user-select: none; + transition: box-shadow 0.15s, opacity 0.15s; + } + .fc:hover { border-color: #bbb; } + .fc-hidden { opacity: 0.45; border-style: dashed; } + .fc-dragging { opacity: 0.3; } + .fc-drag-over { box-shadow: inset 0 0 0 2px var(--uui-color-interactive, #1b264f); } + .fc-type { + font-size: 0.7rem; font-weight: 600; color: #888; text-transform: uppercase; + background: #f0f0f0; padding: 1px 5px; border-radius: 3px; white-space: nowrap; + } + .fc-label { flex: 1; font-size: 0.85rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .fc-req { color: #e53935; font-weight: 700; font-size: 1rem; } + .fc-actions { display: flex; gap: 2px; margin-left: auto; flex-shrink: 0; } + .fc-btn { + border: none; background: none; cursor: pointer; padding: 2px 4px; + font-size: 0.75rem; color: #888; border-radius: 3px; + } + .fc-btn:hover { background: #f0f0f0; color: #333; } + .fc-btn:disabled { opacity: 0.3; cursor: default; } + .fc-btn-danger:hover { background: #fde8e8; color: #c0392b; } + + /* ── Field settings dialog ── */ + .field-dialog { + background: var(--uui-color-surface, #fff); border-radius: 8px; + width: 640px; max-width: 90vw; max-height: 85vh; display: flex; flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + } + .field-dialog-header { + display: flex; justify-content: space-between; align-items: center; + padding: 14px 20px; border-bottom: 1px solid #e0e0e0; + } + .field-dialog-header h3 { margin: 0; font-size: 1rem; } + .field-dialog-body { padding: 16px 20px; overflow-y: auto; flex: 1; } + .field-dialog-footer { padding: 12px 20px; border-top: 1px solid #e0e0e0; display: flex; justify-content: flex-end; } + .fd-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } + .fd-label { font-weight: 600; font-size: 0.85rem; min-width: 80px; } + .fd-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; } + .fd-grid label { display: flex; flex-direction: column; gap: 3px; font-size: 0.8rem; font-weight: 500; } + .fd-toggles { display: flex; gap: 16px; margin-bottom: 12px; flex-wrap: wrap; } + .fd-options { margin-top: 12px; } `; diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js index dc31434..937d379 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js @@ -1,15 +1,11 @@ import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; -/** - * Renders the form editor view. - * @param {object} host - the dashboard element - */ +// ── Main editor ── export function renderEditor(host) { const f = host._editForm; if (!f) return nothing; - - const needsOptions = (t) => ['select', 'radio', 'checkbox'].includes(t); const showSettings = host._showColumnSettings; + if (!f.groups) f.groups = []; return html` @@ -18,325 +14,390 @@ export function renderEditor(host) {

${f.id ? 'Edit' : 'New'} Form

${f.id ? html` - host._viewEntries(f.id)}> - Entries (${host._entryCount ?? 0}) - + host._viewEntries(f.id)}>Entries (${host._entryCount ?? 0}) { host._showColumnSettings = !host._showColumnSettings; host.requestUpdate(); }}> - ⚙ Settings - + @click=${() => { host._showColumnSettings = !host._showColumnSettings; host.requestUpdate(); }}>⚙ Settings ` : nothing} host._saveForm()}>Save Form
- - ${showSettings && f.id ? html` ${_renderEmbedSettings(host, f)} + ${_renderGeneralSettings(host, f)} ${_renderColumnSettings(host, f)} ` : nothing} + ${!f.id ? _renderGeneralSettings(host, f) : nothing} +
+

Groups

+ host._addGroup()}>+ Add Group +
+ ${f.groups.length === 0 ? html`
No groups yet. Add a group to organise fields.
` : nothing} + ${f.groups.map((group, gIdx) => _renderGroupCard(host, group, gIdx))} + + ${host._typePickerIdx >= 0 ? _renderTypePicker(host) : nothing} + ${host._fieldSettingsLoc ? _renderFieldSettingsDialog(host) : nothing}`; +} - -
- - - - - - - - +// ── Group card ── +function _renderGroupCard(host, group, gIdx) { + const f = host._editForm; + if (!group.columns) group.columns = []; + const totalWidth = group.columns.reduce((sum, c) => sum + (c.width || 1), 0); + return html` +
+
+
+ Group #${gIdx + 1} +
+ + ${group.columns.length} column${group.columns.length !== 1 ? 's' : ''} · ${totalWidth}/12 + ${totalWidth > 12 ? html`
⚠ exceeds 12!` : nothing} +
+
+
+ + + host._addColumn(gIdx)}>+ Add Column +
+
+ host._moveGroup(gIdx, -1)} ?disabled=${gIdx === 0}>▲ + host._moveGroup(gIdx, 1)} ?disabled=${gIdx === f.groups.length - 1}>▼ + host._removeGroup(gIdx)}>🗑 +
+
+
+
+
+ ${group.columns.map((col, cIdx) => _renderColumnCard(host, col, gIdx, cIdx, group.columns.length))} +
+
`; +} - -
-

Fields

- host._addField()}>+ Add Field +// ── Column card (draggable) ── +function _renderColumnCard(host, col, gIdx, cIdx, totalCols) { + const widthPct = ((col.width || 12) / 12 * 100).toFixed(2); + return html` +
{ e.dataTransfer.setData('application/col-drag', JSON.stringify({ gIdx, cIdx })); e.dataTransfer.effectAllowed = 'move'; e.currentTarget.classList.add('col-dragging'); }} + @dragend=${(e) => { e.currentTarget.classList.remove('col-dragging'); }} + @dragover=${(e) => { if (e.dataTransfer.types.includes('application/col-drag')) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; e.currentTarget.classList.add('col-drag-over'); } }} + @dragleave=${(e) => { e.currentTarget.classList.remove('col-drag-over'); }} + @drop=${(e) => { + e.preventDefault(); e.currentTarget.classList.remove('col-drag-over'); + try { + const from = JSON.parse(e.dataTransfer.getData('application/col-drag')); + if (from.gIdx === gIdx && from.cIdx !== cIdx) { + const cols = host._editForm.groups[gIdx].columns; const [moved] = cols.splice(from.cIdx, 1); cols.splice(cIdx, 0, moved); host.requestUpdate(); + } else if (from.gIdx !== gIdx) { host._moveColumnTo(from.gIdx, from.cIdx, gIdx); } + } catch {} + }}> +
+ + Col ${cIdx + 1} +
+ ${totalCols > 1 ? html` host._removeColumn(gIdx, cIdx)}>✕` : nothing} +
- ${f.fields.map((field, idx) => _renderFieldCard(host, field, idx, needsOptions))} - - ${host._typePickerIdx >= 0 ? _renderTypePicker(host) : nothing}`; +
+
+ +
+
+ ${col.fields.map((field, fIdx) => _renderFieldCompact(host, field, fIdx, { gIdx, cIdx }))} +
+ host._addFieldToColumn(gIdx, cIdx)}>+ Add Field +
+
+
`; } -/** - * Renders the Embed Code / API settings panel. - */ -function _renderEmbedSettings(host, f) { +// ── Compact field card (summary only) ── +function _renderFieldCompact(host, field, fIdx, loc) { + const typeLabel = host._fieldTypes.find(ft => ft.type === field.type)?.label || field.type; + const label = field.label || field.name || '(no label)'; + const totalFields = host._editForm.groups[loc.gIdx].columns[loc.cIdx].fields.length; + return html` -
-
-

Embed API Settings

+
{ host._fieldSettingsLoc = { ...loc, fIdx }; host.requestUpdate(); }} + @dragstart=${(e) => { e.dataTransfer.setData('application/field-drag', JSON.stringify({ ...loc, fIdx })); e.dataTransfer.effectAllowed = 'move'; e.currentTarget.classList.add('fc-dragging'); }} + @dragend=${(e) => { e.currentTarget.classList.remove('fc-dragging'); }} + @dragover=${(e) => { if (e.dataTransfer.types.includes('application/field-drag')) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; e.currentTarget.classList.add('fc-drag-over'); } }} + @dragleave=${(e) => { e.currentTarget.classList.remove('fc-drag-over'); }} + @drop=${(e) => { + e.preventDefault(); e.currentTarget.classList.remove('fc-drag-over'); + try { + const from = JSON.parse(e.dataTransfer.getData('application/field-drag')); + if (from.gIdx === loc.gIdx && from.cIdx === loc.cIdx && from.fIdx !== fIdx) { + const arr = host._editForm.groups[loc.gIdx].columns[loc.cIdx].fields; + const [moved] = arr.splice(from.fIdx, 1); arr.splice(fIdx, 0, moved); + arr.forEach((f, i) => f.sortOrder = i); host.requestUpdate(); + } else if (from.gIdx !== loc.gIdx || from.cIdx !== loc.cIdx) { + const srcArr = host._editForm.groups[from.gIdx].columns[from.cIdx].fields; + const [moved] = srcArr.splice(from.fIdx, 1); + const destArr = host._editForm.groups[loc.gIdx].columns[loc.cIdx].fields; + moved.sortOrder = fIdx; destArr.splice(fIdx, 0, moved); + destArr.forEach((f, i) => f.sortOrder = i); host.requestUpdate(); + } + } catch {} + }}> + ${label} ${field.required ? html`*` : nothing} + +
+ + + +
-
- POST /api/utpro/simple-form/submit { "alias": "${f.alias}", "data": { ... } } +
`; +} + +// ── Field settings dialog ── +function _renderFieldSettingsDialog(host) { + const loc = host._fieldSettingsLoc; + if (!loc) return nothing; + const field = host._editForm.groups?.[loc.gIdx]?.columns?.[loc.cIdx]?.fields?.[loc.fIdx]; + if (!field) return nothing; + + const needsOptions = ['select', 'radio', 'checkbox'].includes(field.type); + const currentTypeLabel = host._fieldTypes.find(ft => ft.type === field.type)?.label || field.type; + const updateFn = (key, val) => { host._updateFieldInColumn(loc.gIdx, loc.cIdx, loc.fIdx, key, val); }; + const close = () => { host._fieldSettingsLoc = null; host.requestUpdate(); }; + + return html` +
{ if (e.target === e.currentTarget) close(); }}> +
+
+

Field Settings — ${field.label || field.name || '(untitled)'}

+ +
+
+ +
+ updateFn('isHidden', !e.target.checked)} label="Visible"> + updateFn('required', e.target.checked)} label="Required"> + updateFn('isSensitive', e.target.checked)} label="Sensitive Data"> +
+ +
+ +
+ + +
+ + + ${_renderMoveToInDialog(host, loc)} +
+ + ${field.type !== 'div' && field.type !== 'step' ? html` + +
+ + + + + + + +
+ ` : html` + +
+ +
+ + `} + + + ${_renderTypeAttributes(host, field, loc.fIdx, loc)} + + + ${needsOptions ? html` +
+
Options + host._addOptionInColumn(loc.gIdx, loc.cIdx, loc.fIdx)}>+ Option +
+ ${(field.options || []).map((opt, oIdx) => html` +
+ { opt.text = e.target.value; host.requestUpdate(); }}> + { opt.value = e.target.value; host.requestUpdate(); }}> + host._removeOptionInColumn(loc.gIdx, loc.cIdx, loc.fIdx, oIdx)}>✕ +
`)} +
+ ` : nothing} + +
+
+
`; +} + +function _renderMoveToInDialog(host, loc) { + const f = host._editForm; + const destinations = []; + (f.groups || []).forEach((g, gi) => { + (g.columns || []).forEach((c, ci) => { + if (gi === loc.gIdx && ci === loc.cIdx) return; + const gName = g.name || `Group #${gi + 1}`; + const colLabel = g.columns.length > 1 ? ` / Col ${ci + 1}` : ''; + destinations.push({ label: `${gName}${colLabel}`, gIdx: gi, cIdx: ci }); + }); + }); + if (destinations.length === 0) return nothing; + return html` +
+ + +
`; +} + +// ── Shared helpers ── + +function _renderEmbedSettings(host, f) { + return html` +
+

Embed API Settings

+
POST /api/utpro/simple-form/submit { "alias": "${f.alias}", "data": { ... } }
- { f.enableRenderApi = e.target.checked; host.requestUpdate(); }} - label=${f.enableRenderApi ? 'Enabled' : 'Disabled'}> + { f.enableRenderApi = e.target.checked; host.requestUpdate(); }} label=${f.enableRenderApi ? 'Enabled' : 'Disabled'}> GET /api/utpro/simple-form/render/${f.alias}
- { f.enableEntriesApi = e.target.checked; host.requestUpdate(); }} - label=${f.enableEntriesApi ? 'Enabled' : 'Disabled'}> + { f.enableEntriesApi = e.target.checked; host.requestUpdate(); }} label=${f.enableEntriesApi ? 'Enabled' : 'Disabled'}> GET /api/utpro/simple-form/entries/${f.alias}
`; } -/** - * Renders the column visibility settings panel with drag & drop reordering. - */ -function _renderColumnSettings(host, f) { - const allFieldNames = f.fields.map(field => field.name).filter(n => n); +function _renderGeneralSettings(host, f) { + return html` +
+

General Settings

+
+ + + + + + + +
+
`; +} - // Build ordered list: visibleColumns first (in order), then unchecked ones +function _renderColumnSettings(host, f) { + const allFieldNames = (f.groups || []).flatMap(g => (g.columns || []).flatMap(c => c.fields.map(field => field.name).filter(n => n))); let orderedNames; if (f.visibleColumns && f.visibleColumns.length > 0) { const checked = f.visibleColumns.filter(n => allFieldNames.includes(n)); const unchecked = allFieldNames.filter(n => !f.visibleColumns.includes(n)); orderedNames = [...checked, ...unchecked]; - } else { - orderedNames = [...allFieldNames]; - } - + } else { orderedNames = [...allFieldNames]; } return html`
-
-

⚙ Entries Column Settings

- Select which fields to show as columns in the Entries view. Drag to reorder. -
+

⚙ Entries Column Settings

Drag to reorder.
- ${allFieldNames.length === 0 ? html`
No fields yet. Add fields first.
` : nothing} + ${allFieldNames.length === 0 ? html`
No fields yet.
` : nothing} ${orderedNames.map((name, idx) => { - const isVisible = f.visibleColumns === null || f.visibleColumns === undefined - ? true - : f.visibleColumns.includes(name); - return html` - `; - })} + const isVisible = f.visibleColumns == null ? true : f.visibleColumns.includes(name); + return html``; })}
`; } -/** - * Renders the field type picker dialog with search. - */ function _renderTypePicker(host) { const search = (host._typePickerSearch || '').toLowerCase(); - const filtered = host._fieldTypes.filter(ft => - ft.label.toLowerCase().includes(search) || ft.type.toLowerCase().includes(search) - ); - const idx = host._typePickerIdx; - const currentType = host._editForm?.fields[idx]?.type; - + const filtered = host._fieldTypes.filter(ft => ft.label.toLowerCase().includes(search) || ft.type.toLowerCase().includes(search)); + const idx = host._typePickerIdx, gIdx = host._typePickerGroupIdx, cIdx = host._typePickerColIdx ?? -1; + let currentType; + if (gIdx >= 0 && cIdx >= 0) currentType = host._editForm?.groups?.[gIdx]?.columns?.[cIdx]?.fields?.[idx]?.type; return html` -
{ if (e.target === e.currentTarget) { host._typePickerIdx = -1; host.requestUpdate(); } }}> +
{ if (e.target === e.currentTarget) { host._typePickerIdx = -1; host.requestUpdate(); } }}>
-
-

Select Field Type

- { host._typePickerIdx = -1; host.requestUpdate(); }}>✕ -
- +

Select Field Type

+ { host._typePickerIdx = -1; host.requestUpdate(); }}>✕
+
- ${filtered.map(ft => html` - - `)} + ${filtered.map(ft => html``)} ${filtered.length === 0 ? html`
No matching types
` : nothing}
`; } -/** - * Renders type-specific attribute fields. - * Uses field.attributes dict to store extra config per type. - */ -function _renderTypeAttributes(host, field, idx) { - const t = field.type; - if (!field.attributes) field.attributes = {}; - const attr = field.attributes; - const setAttr = (key, val) => { field.attributes[key] = val; host.requestUpdate(); }; - - // Number: min, max, step - if (t === 'number') return html` -
- - - -
`; - - // Date: min, max - if (t === 'date') return html` -
- - -
`; - - // Time: min, max - if (t === 'time') return html` -
- - -
`; - - // Textarea: rows - if (t === 'textarea') return html` -
- -
`; - - // File: accept, maxSize - if (t === 'file') return html` -
- - -
`; - - // Range: min, max, step - if (t === 'range') return html` -
- - - -
`; - - // Accept/Terms: text, linkUrl, linkText - if (t === 'accept') return html` -
- - - -
`; - - // Step: title - if (t === 'step') return html` -
- -
`; - +function _renderTypeAttributes(host, field, idx, loc) { + const t = field.type; if (!field.attributes) field.attributes = {}; + const a = field.attributes, s = (k, v) => { field.attributes[k] = v; host.requestUpdate(); }; + if (t === 'number') return html`
`; + if (t === 'date') return html`
`; + if (t === 'time') return html`
`; + if (t === 'textarea') return html`
`; + if (t === 'file') return html`
`; + if (t === 'range') return html`
`; + if (t === 'accept') return html`
`; + if (t === 'step') return html`
`; return nothing; } - -function _renderFieldCard(host, field, idx, needsOptions) { +function _renderColMoveToSelect(host, gIdx, cIdx) { const f = host._editForm; - const currentTypeLabel = host._fieldTypes.find(ft => ft.type === field.type)?.label || field.type; - - return html` -
-
- #${idx + 1} - - host._updateField(idx, 'isHidden', !e.target.checked)} label=${field.isHidden ? 'Hidden' : 'Visible'}> - ${field.type !== 'div' && field.type !== 'step' ? html` - host._updateField(idx, 'required', e.target.checked)} label="Required"> - host._updateField(idx, 'isSensitive', e.target.checked)} label="Sensitive Data"> - ` : nothing} -
- host._moveField(idx, -1)} ?disabled=${idx === 0}>▲ - host._moveField(idx, 1)} ?disabled=${idx === f.fields.length - 1}>▼ - host._removeField(idx)}>✕ -
-
- ${field.type === 'div' || field.type === 'step' ? html` -
- - -
- ` : html` -
- - - - - - - -
- `} - ${_renderTypeAttributes(host, field, idx)} - ${needsOptions(field.type) ? html` -
-
Options - host._addOption(idx)}>+ Option -
- ${(field.options || []).map((opt, oIdx) => html` -
- { opt.text = e.target.value; host.requestUpdate(); }}> - { opt.value = e.target.value; host.requestUpdate(); }}> - host._removeOption(idx, oIdx)}>✕ -
`)} -
` : nothing} -
`; + if (!f.groups || f.groups.length < 2) return nothing; + const dests = f.groups.map((g, gi) => ({ label: g.name || `Group #${gi + 1}`, gi })).filter(d => d.gi !== gIdx); + if (!dests.length) return nothing; + return html``; } diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/uTPro/simple-form/css/simple-form.css b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/uTPro/simple-form/css/simple-form.css index 3d4279b..886945e 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/uTPro/simple-form/css/simple-form.css +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/uTPro/simple-form/css/simple-form.css @@ -13,6 +13,12 @@ .sf-success { background: #e8fde8; color: #27ae60; } .sf-fail { background: #fde8e8; color: #c0392b; } +/* ── Groups / Fieldsets ── */ +.sf-group { border: none; padding: 0; margin: 0 0 1.5em 0; } +.sf-group-title { font-weight: 700; font-size: 1.1rem; margin-bottom: 0.75em; padding: 0; } +.sf-group-grid { width: 100%; } +.sf-group-field { min-width: 0; } + /* ── Content Block ── */ .sf-content-block { margin: 8px 0; } diff --git a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml index 6f140db..2569a56 100644 --- a/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml +++ b/uTPro/Project/uTPro.Project.Web/Views/Partials/SimpleForm/Default.cshtml @@ -1,3 +1,4 @@ +@using Microsoft.Extensions.DependencyInjection @using uTPro.Extension.CurrentSite @model uTPro.Feature.SimpleForm.Models.FormViewModel @inject ICurrentSiteExtension CurrentSite @@ -9,48 +10,61 @@ var resetText = ViewBag.ResetBtnText as string ?? "RESET"; } -
-
- @foreach (var field in Model.Fields.OrderBy(f => f.SortOrder)) - { - if (field.IsHidden) { continue; } - - var fieldCss = field.CssClass ?? ""; - -
- @{ - var customPartial = $"~/Views/Partials/SimpleForm/Fields/{field.Type}.cshtml"; - var fallbackPartial = "~/Views/Partials/SimpleForm/Fields/_Default.cshtml"; - var viewEngine = ViewContext.HttpContext.RequestServices.GetRequiredService(); - var viewExists = viewEngine.GetView(null, customPartial, false).Success - || viewEngine.FindView(ViewContext, customPartial, false).Success; - } - @if (viewExists) + @foreach (var group in (Model.Groups ?? []).OrderBy(g => g.SortOrder)) + { + var groupCss = group.CssClass ?? ""; +
+ @if (!string.IsNullOrWhiteSpace(group.Name)) + { + @group.Name + } +
+ @foreach (var col in group.Columns) { - @await Html.PartialAsync(customPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) - } - else - { - @await Html.PartialAsync(fallbackPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) + var colWidth = Math.Clamp(col.Width, 1, 12); +
+ @foreach (var field in col.Fields.OrderBy(f => f.SortOrder)) + { + if (field.IsHidden) { continue; } + var fieldCss = field.CssClass ?? ""; +
+ @{ + var customPartial = $"~/Views/Partials/SimpleForm/Fields/{field.Type}.cshtml"; + var fallbackPartial = "~/Views/Partials/SimpleForm/Fields/_Default.cshtml"; + var viewEngine = ViewContext.HttpContext.RequestServices.GetRequiredService(); + var viewExists = viewEngine.GetView(null, customPartial, false).Success + || viewEngine.FindView(ViewContext, customPartial, false).Success; + } + @if (viewExists) + { + @await Html.PartialAsync(customPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) + } + else + { + @await Html.PartialAsync(fallbackPartial, field, new ViewDataDictionary(ViewData) { { "FormId", formId }, { "CurrentSite", CurrentSite } }) + } +
+ } +
}
- } +
+ } -
-
    -
  • - @if (showReset) - { -
  • - } -
-
+
+
    +
  • + @if (showReset) + { +
  • + } +
diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/index.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/index.js index aada3ba..2459f7e 100644 --- a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/index.js +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/index.js @@ -43,6 +43,9 @@ export class UtproSimpleFormDashboard extends UmbLitElement { _entryCount: { type: Number, state: true }, _typePickerIdx: { type: Number, state: true }, _typePickerSearch: { type: String, state: true }, + _typePickerGroupIdx: { type: Number, state: true }, + _typePickerColIdx: { type: Number, state: true }, + _fieldSettingsLoc: { type: Object, state: true }, }; // ── Styles ── @@ -73,6 +76,9 @@ export class UtproSimpleFormDashboard extends UmbLitElement { this._entryCount = 0; this._typePickerIdx = -1; this._typePickerSearch = ''; + this._typePickerGroupIdx = -1; + this._typePickerColIdx = -1; + this._fieldSettingsLoc = null; this.consumeContext(UMB_AUTH_CONTEXT, (ctx) => { this.#authContext = ctx; }); } @@ -118,7 +124,7 @@ export class UtproSimpleFormDashboard extends UmbLitElement { // ── Form CRUD ── _newForm() { this._editForm = { - id: 0, name: '', alias: '', fields: [], + id: 0, name: '', alias: '', fields: [], groups: [], successMessage: 'Thank you!', redirectUrl: '', emailTo: '', emailSubject: '', storeEntries: true, isEnabled: true }; @@ -158,55 +164,159 @@ export class UtproSimpleFormDashboard extends UmbLitElement { } catch (e) { this._msg(e.message, true); } } - // ── Field management ── - _addField() { + // ── Group management ── + _addGroup() { const f = this._editForm; - const idx = f.fields.length; - f.fields = [...f.fields, { + if (!f.groups) f.groups = []; + const idx = f.groups.length; + f.groups = [...f.groups, { id: crypto.randomUUID?.() || Date.now().toString(36), - type: 'text', label: '', name: 'field_' + idx, + name: '', + cssClass: '', + columns: [{ id: crypto.randomUUID?.() || Date.now().toString(36), width: 12, fields: [] }], + sortOrder: idx + }]; + this.requestUpdate(); + } + + _removeGroup(gIdx) { + if (!confirm('Remove this group and all its columns/fields?')) return; + this._editForm.groups = this._editForm.groups.filter((_, i) => i !== gIdx); + this._editForm.groups.forEach((g, i) => g.sortOrder = i); + this.requestUpdate(); + } + + _moveGroup(gIdx, dir) { + const arr = [...this._editForm.groups]; + const newIdx = gIdx + dir; + if (newIdx < 0 || newIdx >= arr.length) return; + [arr[gIdx], arr[newIdx]] = [arr[newIdx], arr[gIdx]]; + arr.forEach((g, i) => g.sortOrder = i); + this._editForm.groups = arr; + this.requestUpdate(); + } + + _updateGroup(gIdx, key, val) { + this._editForm.groups[gIdx][key] = val; + this.requestUpdate(); + } + + // ── Column management within a group ── + _addColumn(gIdx) { + const g = this._editForm.groups[gIdx]; + if (!g.columns) g.columns = []; + g.columns = [...g.columns, { + id: crypto.randomUUID?.() || Date.now().toString(36), + width: 6, + fields: [] + }]; + this.requestUpdate(); + } + + _removeColumn(gIdx, cIdx) { + if (!confirm('Remove this column and all its fields?')) return; + this._editForm.groups[gIdx].columns = this._editForm.groups[gIdx].columns.filter((_, i) => i !== cIdx); + this.requestUpdate(); + } + + _updateColumnWidth(gIdx, cIdx, val) { + this._editForm.groups[gIdx].columns[cIdx].width = Math.min(12, Math.max(1, parseInt(val) || 1)); + this.requestUpdate(); + } + + /** + * Move an entire column (with all its fields) to another group. + * @param {number} fromGIdx - source group index + * @param {number} cIdx - column index within source group + * @param {number} toGIdx - destination group index + */ + _moveColumnTo(fromGIdx, cIdx, toGIdx) { + const f = this._editForm; + const col = f.groups[fromGIdx].columns.splice(cIdx, 1)[0]; + if (!col) return; + f.groups[toGIdx].columns.push(col); + this.requestUpdate(); + } + + _swapColumn(gIdx, cIdx, dir) { + const cols = this._editForm.groups[gIdx].columns; + const newIdx = cIdx + dir; + if (newIdx < 0 || newIdx >= cols.length) return; + [cols[cIdx], cols[newIdx]] = [cols[newIdx], cols[cIdx]]; + this.requestUpdate(); + } + + // ── Field management within a column ── + _addFieldToColumn(gIdx, cIdx) { + const col = this._editForm.groups[gIdx].columns[cIdx]; + const idx = col.fields.length; + col.fields = [...col.fields, { + id: crypto.randomUUID?.() || Date.now().toString(36), + type: 'text', label: '', name: 'field_' + Date.now().toString(36), placeholder: '', cssClass: '', required: false, validation: '', validationMessage: '', defaultValue: '', - options: [], sortOrder: idx, colSpan: 1, attributes: {} + options: [], sortOrder: idx, attributes: {} }]; this.requestUpdate(); } - _removeField(idx) { - const removedName = this._editForm.fields[idx]?.name; - this._editForm.fields = this._editForm.fields.filter((_, i) => i !== idx); + _removeFieldFromColumn(gIdx, cIdx, fIdx) { + const removedName = this._editForm.groups[gIdx].columns[cIdx].fields[fIdx]?.name; + this._editForm.groups[gIdx].columns[cIdx].fields = this._editForm.groups[gIdx].columns[cIdx].fields.filter((_, i) => i !== fIdx); if (removedName && this._editForm.visibleColumns) { this._editForm.visibleColumns = this._editForm.visibleColumns.filter(c => c !== removedName); } this.requestUpdate(); } - _moveField(idx, dir) { - const arr = [...this._editForm.fields]; - const newIdx = idx + dir; + _moveFieldInColumn(gIdx, cIdx, fIdx, dir) { + const arr = [...this._editForm.groups[gIdx].columns[cIdx].fields]; + const newIdx = fIdx + dir; if (newIdx < 0 || newIdx >= arr.length) return; - [arr[idx], arr[newIdx]] = [arr[newIdx], arr[idx]]; + [arr[fIdx], arr[newIdx]] = [arr[newIdx], arr[fIdx]]; arr.forEach((f, i) => f.sortOrder = i); - this._editForm.fields = arr; + this._editForm.groups[gIdx].columns[cIdx].fields = arr; this.requestUpdate(); } - _updateField(idx, key, val) { - this._editForm.fields[idx][key] = val; + _updateFieldInColumn(gIdx, cIdx, fIdx, key, val) { + this._editForm.groups[gIdx].columns[cIdx].fields[fIdx][key] = val; if (key === 'type' && val === 'password') { - this._editForm.fields[idx].isSensitive = true; + this._editForm.groups[gIdx].columns[cIdx].fields[fIdx].isSensitive = true; } this.requestUpdate(); } - _addOption(idx) { - if (!this._editForm.fields[idx].options) this._editForm.fields[idx].options = []; - this._editForm.fields[idx].options.push({ text: '', value: '' }); + _addOptionInColumn(gIdx, cIdx, fIdx) { + if (!this._editForm.groups[gIdx].columns[cIdx].fields[fIdx].options) + this._editForm.groups[gIdx].columns[cIdx].fields[fIdx].options = []; + this._editForm.groups[gIdx].columns[cIdx].fields[fIdx].options.push({ text: '', value: '' }); + this.requestUpdate(); + } + + _removeOptionInColumn(gIdx, cIdx, fIdx, oIdx) { + this._editForm.groups[gIdx].columns[cIdx].fields[fIdx].options.splice(oIdx, 1); this.requestUpdate(); } - _removeOption(fIdx, oIdx) { - this._editForm.fields[fIdx].options.splice(oIdx, 1); + // ── Move field between groups/columns ── + /** + * Move a field from one location to another. + * @param {object} from - { gIdx, cIdx, fIdx } source (-1 for ungrouped) + * @param {object} to - { gIdx, cIdx } destination (-1 for ungrouped) + */ + _moveFieldTo(from, to) { + const f = this._editForm; + + // Remove from source column + const field = f.groups[from.gIdx].columns[from.cIdx].fields.splice(from.fIdx, 1)[0]; + if (!field) return; + + // Add to destination column + const destCol = f.groups[to.gIdx].columns[to.cIdx]; + field.sortOrder = destCol.fields.length; + destCol.fields.push(field); + this.requestUpdate(); } diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js index 8c50c36..7e733fc 100644 --- a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js @@ -185,6 +185,7 @@ export const dashboardStyles = css` background: rgba(0,0,0,0.5); z-index: 9999; display: flex; justify-content: center; align-items: center; } + .overlay.overlay-top { z-index: 10001; } .detail-panel { background: var(--uui-color-surface, #fff); border-radius: 8px; width: 600px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column; @@ -202,4 +203,129 @@ export const dashboardStyles = css` .detail-label { font-weight: 600; min-width: 120px; color: #555; font-size: 0.9rem; } .detail-value { flex: 1; word-break: break-word; white-space: pre-wrap; } .detail-footer { padding: 12px 20px; border-top: 1px solid #e0e0e0; display: flex; justify-content: flex-end; } + + /* ── Group cards ── */ + .group-card { + border: 2px solid var(--uui-color-border, #ccc); border-radius: 8px; + margin-bottom: 16px; overflow: hidden; + background: var(--uui-color-surface, #fff); + } + .group-header { + display: flex; align-items: center; gap: 12px; padding: 12px 16px; + background: var(--uui-color-surface-alt, #f0f0f5); flex-wrap: wrap; + } + .group-num { font-weight: 700; font-size: 1rem; color: #555; min-width: 80px; } + .group-settings { display: flex; gap: 10px; flex: 1; flex-wrap: wrap; align-items: flex-end; } + .group-setting-label { + display: flex; flex-direction: column; gap: 3px; + font-size: 0.8rem; font-weight: 500; min-width: 100px; + } + .group-setting-label uui-input { width: 100%; min-width: 80px; } + .group-actions { display: flex; gap: 4px; margin-left: auto; } + .group-preview { + padding: 10px 16px; border-bottom: 1px solid #eee; + background: var(--uui-color-surface-alt, #fafafa); + } + .group-preview-label { font-size: 0.8rem; color: #666; margin-bottom: 6px; display: block; } + .group-col-preview { min-height: 32px; } + .group-col-cell { + background: #e0e0e0; border-radius: 4px; padding: 6px 10px; + text-align: center; font-size: 0.8rem; font-weight: 600; color: #555; + } + .group-grid-empty { + grid-column: 1 / -1; text-align: center; color: #aaa; + font-size: 0.8rem; font-style: italic; padding: 4px; + } + .group-columns-container { + display: flex; gap: 12px; padding: 12px 16px; flex-wrap: wrap; + } + + /* ── Column cards within a group ── */ + .col-card { + min-width: 200px; box-sizing: border-box; + border: 1px dashed var(--uui-color-border, #ccc); border-radius: 6px; + background: var(--uui-color-surface-alt, #fafafa); + } + .col-header { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; + background: var(--uui-color-surface-alt, #f0f0f0); + border-bottom: 1px solid #e0e0e0; + } + .col-num { font-weight: 600; font-size: 0.85rem; color: #555; } + .col-width-label { + display: flex; align-items: center; gap: 4px; + font-size: 0.8rem; font-weight: 500; + } + .col-width-label uui-input { width: 55px; } + .col-actions { margin-left: auto; } + .col-fields { padding: 8px 10px; } + .col-add-field { + text-align: center; padding: 6px 0; margin-top: 4px; + border-top: 1px dashed #ddd; + } + + /* ── Move to select ── */ + .move-to-select { + padding: 3px 6px; border: 1px solid #ccc; border-radius: 4px; + font-size: 0.78rem; background: #fff; height: 33px; box-sizing: border-box; + cursor: pointer; color: #555; + } + .move-to-select:hover { border-color: #888; } + + /* ── Column drag & drop ── */ + .col-drag-handle { + cursor: grab; color: #aaa; font-size: 0.9rem; user-select: none; + } + .col-drag-handle:active { cursor: grabbing; } + .col-card.col-dragging { opacity: 0.4; } + .col-card.col-drag-over { + box-shadow: inset 0 0 0 2px var(--uui-color-interactive, #1b264f); + border-color: var(--uui-color-interactive, #1b264f); + } + + /* ── Compact field card ── */ + .fc { + display: flex; align-items: center; gap: 6px; padding: 6px 8px; + border: 1px solid #e0e0e0; border-radius: 4px; margin-bottom: 4px; + background: #fff; cursor: grab; user-select: none; + transition: box-shadow 0.15s, opacity 0.15s; + } + .fc:hover { border-color: #bbb; } + .fc-hidden { opacity: 0.45; border-style: dashed; } + .fc-dragging { opacity: 0.3; } + .fc-drag-over { box-shadow: inset 0 0 0 2px var(--uui-color-interactive, #1b264f); } + .fc-type { + font-size: 0.7rem; font-weight: 600; color: #888; text-transform: uppercase; + background: #f0f0f0; padding: 1px 5px; border-radius: 3px; white-space: nowrap; + } + .fc-label { flex: 1; font-size: 0.85rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .fc-req { color: #e53935; font-weight: 700; font-size: 1rem; } + .fc-actions { display: flex; gap: 2px; margin-left: auto; flex-shrink: 0; } + .fc-btn { + border: none; background: none; cursor: pointer; padding: 2px 4px; + font-size: 0.75rem; color: #888; border-radius: 3px; + } + .fc-btn:hover { background: #f0f0f0; color: #333; } + .fc-btn:disabled { opacity: 0.3; cursor: default; } + .fc-btn-danger:hover { background: #fde8e8; color: #c0392b; } + + /* ── Field settings dialog ── */ + .field-dialog { + background: var(--uui-color-surface, #fff); border-radius: 8px; + width: 640px; max-width: 90vw; max-height: 85vh; display: flex; flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + } + .field-dialog-header { + display: flex; justify-content: space-between; align-items: center; + padding: 14px 20px; border-bottom: 1px solid #e0e0e0; + } + .field-dialog-header h3 { margin: 0; font-size: 1rem; } + .field-dialog-body { padding: 16px 20px; overflow-y: auto; flex: 1; } + .field-dialog-footer { padding: 12px 20px; border-top: 1px solid #e0e0e0; display: flex; justify-content: flex-end; } + .fd-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } + .fd-label { font-weight: 600; font-size: 0.85rem; min-width: 80px; } + .fd-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; } + .fd-grid label { display: flex; flex-direction: column; gap: 3px; font-size: 0.8rem; font-weight: 500; } + .fd-toggles { display: flex; gap: 16px; margin-bottom: 12px; flex-wrap: wrap; } + .fd-options { margin-top: 12px; } `; diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js index dc31434..937d379 100644 --- a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js @@ -1,15 +1,11 @@ import { html, nothing } from '@umbraco-cms/backoffice/external/lit'; -/** - * Renders the form editor view. - * @param {object} host - the dashboard element - */ +// ── Main editor ── export function renderEditor(host) { const f = host._editForm; if (!f) return nothing; - - const needsOptions = (t) => ['select', 'radio', 'checkbox'].includes(t); const showSettings = host._showColumnSettings; + if (!f.groups) f.groups = []; return html` @@ -18,325 +14,390 @@ export function renderEditor(host) {

${f.id ? 'Edit' : 'New'} Form

${f.id ? html` - host._viewEntries(f.id)}> - Entries (${host._entryCount ?? 0}) - + host._viewEntries(f.id)}>Entries (${host._entryCount ?? 0}) { host._showColumnSettings = !host._showColumnSettings; host.requestUpdate(); }}> - ⚙ Settings - + @click=${() => { host._showColumnSettings = !host._showColumnSettings; host.requestUpdate(); }}>⚙ Settings ` : nothing} host._saveForm()}>Save Form
- - ${showSettings && f.id ? html` ${_renderEmbedSettings(host, f)} + ${_renderGeneralSettings(host, f)} ${_renderColumnSettings(host, f)} ` : nothing} + ${!f.id ? _renderGeneralSettings(host, f) : nothing} +
+

Groups

+ host._addGroup()}>+ Add Group +
+ ${f.groups.length === 0 ? html`
No groups yet. Add a group to organise fields.
` : nothing} + ${f.groups.map((group, gIdx) => _renderGroupCard(host, group, gIdx))} + + ${host._typePickerIdx >= 0 ? _renderTypePicker(host) : nothing} + ${host._fieldSettingsLoc ? _renderFieldSettingsDialog(host) : nothing}`; +} - -
- - - - - - - - +// ── Group card ── +function _renderGroupCard(host, group, gIdx) { + const f = host._editForm; + if (!group.columns) group.columns = []; + const totalWidth = group.columns.reduce((sum, c) => sum + (c.width || 1), 0); + return html` +
+
+
+ Group #${gIdx + 1} +
+ + ${group.columns.length} column${group.columns.length !== 1 ? 's' : ''} · ${totalWidth}/12 + ${totalWidth > 12 ? html`
⚠ exceeds 12!` : nothing} +
+
+
+ + + host._addColumn(gIdx)}>+ Add Column +
+
+ host._moveGroup(gIdx, -1)} ?disabled=${gIdx === 0}>▲ + host._moveGroup(gIdx, 1)} ?disabled=${gIdx === f.groups.length - 1}>▼ + host._removeGroup(gIdx)}>🗑 +
+
+
+
+
+ ${group.columns.map((col, cIdx) => _renderColumnCard(host, col, gIdx, cIdx, group.columns.length))} +
+
`; +} - -
-

Fields

- host._addField()}>+ Add Field +// ── Column card (draggable) ── +function _renderColumnCard(host, col, gIdx, cIdx, totalCols) { + const widthPct = ((col.width || 12) / 12 * 100).toFixed(2); + return html` +
{ e.dataTransfer.setData('application/col-drag', JSON.stringify({ gIdx, cIdx })); e.dataTransfer.effectAllowed = 'move'; e.currentTarget.classList.add('col-dragging'); }} + @dragend=${(e) => { e.currentTarget.classList.remove('col-dragging'); }} + @dragover=${(e) => { if (e.dataTransfer.types.includes('application/col-drag')) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; e.currentTarget.classList.add('col-drag-over'); } }} + @dragleave=${(e) => { e.currentTarget.classList.remove('col-drag-over'); }} + @drop=${(e) => { + e.preventDefault(); e.currentTarget.classList.remove('col-drag-over'); + try { + const from = JSON.parse(e.dataTransfer.getData('application/col-drag')); + if (from.gIdx === gIdx && from.cIdx !== cIdx) { + const cols = host._editForm.groups[gIdx].columns; const [moved] = cols.splice(from.cIdx, 1); cols.splice(cIdx, 0, moved); host.requestUpdate(); + } else if (from.gIdx !== gIdx) { host._moveColumnTo(from.gIdx, from.cIdx, gIdx); } + } catch {} + }}> +
+ + Col ${cIdx + 1} +
+ ${totalCols > 1 ? html` host._removeColumn(gIdx, cIdx)}>✕` : nothing} +
- ${f.fields.map((field, idx) => _renderFieldCard(host, field, idx, needsOptions))} - - ${host._typePickerIdx >= 0 ? _renderTypePicker(host) : nothing}`; +
+
+ +
+
+ ${col.fields.map((field, fIdx) => _renderFieldCompact(host, field, fIdx, { gIdx, cIdx }))} +
+ host._addFieldToColumn(gIdx, cIdx)}>+ Add Field +
+
+
`; } -/** - * Renders the Embed Code / API settings panel. - */ -function _renderEmbedSettings(host, f) { +// ── Compact field card (summary only) ── +function _renderFieldCompact(host, field, fIdx, loc) { + const typeLabel = host._fieldTypes.find(ft => ft.type === field.type)?.label || field.type; + const label = field.label || field.name || '(no label)'; + const totalFields = host._editForm.groups[loc.gIdx].columns[loc.cIdx].fields.length; + return html` -
-
-

Embed API Settings

+
{ host._fieldSettingsLoc = { ...loc, fIdx }; host.requestUpdate(); }} + @dragstart=${(e) => { e.dataTransfer.setData('application/field-drag', JSON.stringify({ ...loc, fIdx })); e.dataTransfer.effectAllowed = 'move'; e.currentTarget.classList.add('fc-dragging'); }} + @dragend=${(e) => { e.currentTarget.classList.remove('fc-dragging'); }} + @dragover=${(e) => { if (e.dataTransfer.types.includes('application/field-drag')) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; e.currentTarget.classList.add('fc-drag-over'); } }} + @dragleave=${(e) => { e.currentTarget.classList.remove('fc-drag-over'); }} + @drop=${(e) => { + e.preventDefault(); e.currentTarget.classList.remove('fc-drag-over'); + try { + const from = JSON.parse(e.dataTransfer.getData('application/field-drag')); + if (from.gIdx === loc.gIdx && from.cIdx === loc.cIdx && from.fIdx !== fIdx) { + const arr = host._editForm.groups[loc.gIdx].columns[loc.cIdx].fields; + const [moved] = arr.splice(from.fIdx, 1); arr.splice(fIdx, 0, moved); + arr.forEach((f, i) => f.sortOrder = i); host.requestUpdate(); + } else if (from.gIdx !== loc.gIdx || from.cIdx !== loc.cIdx) { + const srcArr = host._editForm.groups[from.gIdx].columns[from.cIdx].fields; + const [moved] = srcArr.splice(from.fIdx, 1); + const destArr = host._editForm.groups[loc.gIdx].columns[loc.cIdx].fields; + moved.sortOrder = fIdx; destArr.splice(fIdx, 0, moved); + destArr.forEach((f, i) => f.sortOrder = i); host.requestUpdate(); + } + } catch {} + }}> + ${label} ${field.required ? html`*` : nothing} + +
+ + + +
-
- POST /api/utpro/simple-form/submit { "alias": "${f.alias}", "data": { ... } } +
`; +} + +// ── Field settings dialog ── +function _renderFieldSettingsDialog(host) { + const loc = host._fieldSettingsLoc; + if (!loc) return nothing; + const field = host._editForm.groups?.[loc.gIdx]?.columns?.[loc.cIdx]?.fields?.[loc.fIdx]; + if (!field) return nothing; + + const needsOptions = ['select', 'radio', 'checkbox'].includes(field.type); + const currentTypeLabel = host._fieldTypes.find(ft => ft.type === field.type)?.label || field.type; + const updateFn = (key, val) => { host._updateFieldInColumn(loc.gIdx, loc.cIdx, loc.fIdx, key, val); }; + const close = () => { host._fieldSettingsLoc = null; host.requestUpdate(); }; + + return html` +
{ if (e.target === e.currentTarget) close(); }}> +
+
+

Field Settings — ${field.label || field.name || '(untitled)'}

+ +
+
+ +
+ updateFn('isHidden', !e.target.checked)} label="Visible"> + updateFn('required', e.target.checked)} label="Required"> + updateFn('isSensitive', e.target.checked)} label="Sensitive Data"> +
+ +
+ +
+ + +
+ + + ${_renderMoveToInDialog(host, loc)} +
+ + ${field.type !== 'div' && field.type !== 'step' ? html` + +
+ + + + + + + +
+ ` : html` + +
+ +
+ + `} + + + ${_renderTypeAttributes(host, field, loc.fIdx, loc)} + + + ${needsOptions ? html` +
+
Options + host._addOptionInColumn(loc.gIdx, loc.cIdx, loc.fIdx)}>+ Option +
+ ${(field.options || []).map((opt, oIdx) => html` +
+ { opt.text = e.target.value; host.requestUpdate(); }}> + { opt.value = e.target.value; host.requestUpdate(); }}> + host._removeOptionInColumn(loc.gIdx, loc.cIdx, loc.fIdx, oIdx)}>✕ +
`)} +
+ ` : nothing} + +
+
+
`; +} + +function _renderMoveToInDialog(host, loc) { + const f = host._editForm; + const destinations = []; + (f.groups || []).forEach((g, gi) => { + (g.columns || []).forEach((c, ci) => { + if (gi === loc.gIdx && ci === loc.cIdx) return; + const gName = g.name || `Group #${gi + 1}`; + const colLabel = g.columns.length > 1 ? ` / Col ${ci + 1}` : ''; + destinations.push({ label: `${gName}${colLabel}`, gIdx: gi, cIdx: ci }); + }); + }); + if (destinations.length === 0) return nothing; + return html` +
+ + +
`; +} + +// ── Shared helpers ── + +function _renderEmbedSettings(host, f) { + return html` +
+

Embed API Settings

+
POST /api/utpro/simple-form/submit { "alias": "${f.alias}", "data": { ... } }
- { f.enableRenderApi = e.target.checked; host.requestUpdate(); }} - label=${f.enableRenderApi ? 'Enabled' : 'Disabled'}> + { f.enableRenderApi = e.target.checked; host.requestUpdate(); }} label=${f.enableRenderApi ? 'Enabled' : 'Disabled'}> GET /api/utpro/simple-form/render/${f.alias}
- { f.enableEntriesApi = e.target.checked; host.requestUpdate(); }} - label=${f.enableEntriesApi ? 'Enabled' : 'Disabled'}> + { f.enableEntriesApi = e.target.checked; host.requestUpdate(); }} label=${f.enableEntriesApi ? 'Enabled' : 'Disabled'}> GET /api/utpro/simple-form/entries/${f.alias}
`; } -/** - * Renders the column visibility settings panel with drag & drop reordering. - */ -function _renderColumnSettings(host, f) { - const allFieldNames = f.fields.map(field => field.name).filter(n => n); +function _renderGeneralSettings(host, f) { + return html` +
+

General Settings

+
+ + + + + + + +
+
`; +} - // Build ordered list: visibleColumns first (in order), then unchecked ones +function _renderColumnSettings(host, f) { + const allFieldNames = (f.groups || []).flatMap(g => (g.columns || []).flatMap(c => c.fields.map(field => field.name).filter(n => n))); let orderedNames; if (f.visibleColumns && f.visibleColumns.length > 0) { const checked = f.visibleColumns.filter(n => allFieldNames.includes(n)); const unchecked = allFieldNames.filter(n => !f.visibleColumns.includes(n)); orderedNames = [...checked, ...unchecked]; - } else { - orderedNames = [...allFieldNames]; - } - + } else { orderedNames = [...allFieldNames]; } return html`
-
-

⚙ Entries Column Settings

- Select which fields to show as columns in the Entries view. Drag to reorder. -
+

⚙ Entries Column Settings

Drag to reorder.
- ${allFieldNames.length === 0 ? html`
No fields yet. Add fields first.
` : nothing} + ${allFieldNames.length === 0 ? html`
No fields yet.
` : nothing} ${orderedNames.map((name, idx) => { - const isVisible = f.visibleColumns === null || f.visibleColumns === undefined - ? true - : f.visibleColumns.includes(name); - return html` - `; - })} + const isVisible = f.visibleColumns == null ? true : f.visibleColumns.includes(name); + return html``; })}
`; } -/** - * Renders the field type picker dialog with search. - */ function _renderTypePicker(host) { const search = (host._typePickerSearch || '').toLowerCase(); - const filtered = host._fieldTypes.filter(ft => - ft.label.toLowerCase().includes(search) || ft.type.toLowerCase().includes(search) - ); - const idx = host._typePickerIdx; - const currentType = host._editForm?.fields[idx]?.type; - + const filtered = host._fieldTypes.filter(ft => ft.label.toLowerCase().includes(search) || ft.type.toLowerCase().includes(search)); + const idx = host._typePickerIdx, gIdx = host._typePickerGroupIdx, cIdx = host._typePickerColIdx ?? -1; + let currentType; + if (gIdx >= 0 && cIdx >= 0) currentType = host._editForm?.groups?.[gIdx]?.columns?.[cIdx]?.fields?.[idx]?.type; return html` -
{ if (e.target === e.currentTarget) { host._typePickerIdx = -1; host.requestUpdate(); } }}> +
{ if (e.target === e.currentTarget) { host._typePickerIdx = -1; host.requestUpdate(); } }}>
-
-

Select Field Type

- { host._typePickerIdx = -1; host.requestUpdate(); }}>✕ -
- +

Select Field Type

+ { host._typePickerIdx = -1; host.requestUpdate(); }}>✕
+
- ${filtered.map(ft => html` - - `)} + ${filtered.map(ft => html``)} ${filtered.length === 0 ? html`
No matching types
` : nothing}
`; } -/** - * Renders type-specific attribute fields. - * Uses field.attributes dict to store extra config per type. - */ -function _renderTypeAttributes(host, field, idx) { - const t = field.type; - if (!field.attributes) field.attributes = {}; - const attr = field.attributes; - const setAttr = (key, val) => { field.attributes[key] = val; host.requestUpdate(); }; - - // Number: min, max, step - if (t === 'number') return html` -
- - - -
`; - - // Date: min, max - if (t === 'date') return html` -
- - -
`; - - // Time: min, max - if (t === 'time') return html` -
- - -
`; - - // Textarea: rows - if (t === 'textarea') return html` -
- -
`; - - // File: accept, maxSize - if (t === 'file') return html` -
- - -
`; - - // Range: min, max, step - if (t === 'range') return html` -
- - - -
`; - - // Accept/Terms: text, linkUrl, linkText - if (t === 'accept') return html` -
- - - -
`; - - // Step: title - if (t === 'step') return html` -
- -
`; - +function _renderTypeAttributes(host, field, idx, loc) { + const t = field.type; if (!field.attributes) field.attributes = {}; + const a = field.attributes, s = (k, v) => { field.attributes[k] = v; host.requestUpdate(); }; + if (t === 'number') return html`
`; + if (t === 'date') return html`
`; + if (t === 'time') return html`
`; + if (t === 'textarea') return html`
`; + if (t === 'file') return html`
`; + if (t === 'range') return html`
`; + if (t === 'accept') return html`
`; + if (t === 'step') return html`
`; return nothing; } - -function _renderFieldCard(host, field, idx, needsOptions) { +function _renderColMoveToSelect(host, gIdx, cIdx) { const f = host._editForm; - const currentTypeLabel = host._fieldTypes.find(ft => ft.type === field.type)?.label || field.type; - - return html` -
-
- #${idx + 1} - - host._updateField(idx, 'isHidden', !e.target.checked)} label=${field.isHidden ? 'Hidden' : 'Visible'}> - ${field.type !== 'div' && field.type !== 'step' ? html` - host._updateField(idx, 'required', e.target.checked)} label="Required"> - host._updateField(idx, 'isSensitive', e.target.checked)} label="Sensitive Data"> - ` : nothing} -
- host._moveField(idx, -1)} ?disabled=${idx === 0}>▲ - host._moveField(idx, 1)} ?disabled=${idx === f.fields.length - 1}>▼ - host._removeField(idx)}>✕ -
-
- ${field.type === 'div' || field.type === 'step' ? html` -
- - -
- ` : html` -
- - - - - - - -
- `} - ${_renderTypeAttributes(host, field, idx)} - ${needsOptions(field.type) ? html` -
-
Options - host._addOption(idx)}>+ Option -
- ${(field.options || []).map((opt, oIdx) => html` -
- { opt.text = e.target.value; host.requestUpdate(); }}> - { opt.value = e.target.value; host.requestUpdate(); }}> - host._removeOption(idx, oIdx)}>✕ -
`)} -
` : nothing} -
`; + if (!f.groups || f.groups.length < 2) return nothing; + const dests = f.groups.map((g, gi) => ({ label: g.name || `Group #${gi + 1}`, gi })).filter(d => d.gi !== gIdx); + if (!dests.length) return nothing; + return html``; } diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/css/simple-form.css b/uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/css/simple-form.css index 3d4279b..886945e 100644 --- a/uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/css/simple-form.css +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/uTPro/simple-form/css/simple-form.css @@ -13,6 +13,12 @@ .sf-success { background: #e8fde8; color: #27ae60; } .sf-fail { background: #fde8e8; color: #c0392b; } +/* ── Groups / Fieldsets ── */ +.sf-group { border: none; padding: 0; margin: 0 0 1.5em 0; } +.sf-group-title { font-weight: 700; font-size: 1.1rem; margin-bottom: 0.75em; padding: 0; } +.sf-group-grid { width: 100%; } +.sf-group-field { min-width: 0; } + /* ── Content Block ── */ .sf-content-block { margin: 8px 0; } From d30c53be29c94331232d2212609fabeaf28b74e7 Mon Sep 17 00:00:00 2001 From: Nguyen Thien Tu <15050790+thientu995@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:23:35 +0700 Subject: [PATCH 5/9] Update UI --- .../wwwroot/App_Plugins/simple-form/styles.js | 5 + .../simple-form/views/editor-view.js | 147 +++++++++--------- .../wwwroot/App_Plugins/simple-form/styles.js | 5 + .../simple-form/views/editor-view.js | 147 +++++++++--------- 4 files changed, 152 insertions(+), 152 deletions(-) diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/styles.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/styles.js index 7e733fc..5827a80 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/styles.js +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/styles.js @@ -328,4 +328,9 @@ export const dashboardStyles = css` .fd-grid label { display: flex; flex-direction: column; gap: 3px; font-size: 0.8rem; font-weight: 500; } .fd-toggles { display: flex; gap: 16px; margin-bottom: 12px; flex-wrap: wrap; } .fd-options { margin-top: 12px; } + .fd-html-textarea { + width: 100%; min-height: 120px; padding: 8px; border: 1px solid #ccc; border-radius: 4px; + font-family: monospace; font-size: 0.85rem; resize: vertical; box-sizing: border-box; + background: #fff; margin-top: 4px; + } `; diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js index 937d379..1782293 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js @@ -89,14 +89,14 @@ function _renderColumnCard(host, col, gIdx, cIdx, totalCols) { @dragover=${(e) => { if (e.dataTransfer.types.includes('application/col-drag')) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; e.currentTarget.classList.add('col-drag-over'); } }} @dragleave=${(e) => { e.currentTarget.classList.remove('col-drag-over'); }} @drop=${(e) => { - e.preventDefault(); e.currentTarget.classList.remove('col-drag-over'); - try { - const from = JSON.parse(e.dataTransfer.getData('application/col-drag')); - if (from.gIdx === gIdx && from.cIdx !== cIdx) { - const cols = host._editForm.groups[gIdx].columns; const [moved] = cols.splice(from.cIdx, 1); cols.splice(cIdx, 0, moved); host.requestUpdate(); - } else if (from.gIdx !== gIdx) { host._moveColumnTo(from.gIdx, from.cIdx, gIdx); } - } catch {} - }}> + e.preventDefault(); e.currentTarget.classList.remove('col-drag-over'); + try { + const from = JSON.parse(e.dataTransfer.getData('application/col-drag')); + if (from.gIdx === gIdx && from.cIdx !== cIdx) { + const cols = host._editForm.groups[gIdx].columns; const [moved] = cols.splice(from.cIdx, 1); cols.splice(cIdx, 0, moved); host.requestUpdate(); + } else if (from.gIdx !== gIdx) { host._moveColumnTo(from.gIdx, from.cIdx, gIdx); } + } catch { } + }}>
Col ${cIdx + 1} @@ -106,7 +106,7 @@ function _renderColumnCard(host, col, gIdx, cIdx, totalCols) {
-
`; @@ -271,18 +263,17 @@ function _renderMoveToInDialog(host, loc) { }); if (destinations.length === 0) return nothing; return html` -
- +
`; + `; } // ── Shared helpers ── @@ -336,23 +327,27 @@ function _renderColumnSettings(host, f) {
${allFieldNames.length === 0 ? html`
No fields yet.
` : nothing} ${orderedNames.map((name, idx) => { - const isVisible = f.visibleColumns == null ? true : f.visibleColumns.includes(name); - return html``; })} + if (!f.visibleColumns) f.visibleColumns = [...allFieldNames]; + if (e.target.checked) { if (!f.visibleColumns.includes(name)) f.visibleColumns.push(name); } + else { f.visibleColumns = f.visibleColumns.filter(c => c !== name); } host.requestUpdate(); + }} /> + ${name}`; + })}
`; } @@ -382,14 +377,14 @@ function _renderTypePicker(host) { function _renderTypeAttributes(host, field, idx, loc) { const t = field.type; if (!field.attributes) field.attributes = {}; const a = field.attributes, s = (k, v) => { field.attributes[k] = v; host.requestUpdate(); }; - if (t === 'number') return html`
`; - if (t === 'date') return html`
`; - if (t === 'time') return html`
`; - if (t === 'textarea') return html`
`; - if (t === 'file') return html`
`; - if (t === 'range') return html`
`; - if (t === 'accept') return html`
`; - if (t === 'step') return html`
`; + if (t === 'number') return html`
`; + if (t === 'date') return html`
`; + if (t === 'time') return html`
`; + if (t === 'textarea') return html`
`; + if (t === 'file') return html`
`; + if (t === 'range') return html`
`; + if (t === 'accept') return html`
`; + if (t === 'step') return html`
`; return nothing; } diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js index 7e733fc..5827a80 100644 --- a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/styles.js @@ -328,4 +328,9 @@ export const dashboardStyles = css` .fd-grid label { display: flex; flex-direction: column; gap: 3px; font-size: 0.8rem; font-weight: 500; } .fd-toggles { display: flex; gap: 16px; margin-bottom: 12px; flex-wrap: wrap; } .fd-options { margin-top: 12px; } + .fd-html-textarea { + width: 100%; min-height: 120px; padding: 8px; border: 1px solid #ccc; border-radius: 4px; + font-family: monospace; font-size: 0.85rem; resize: vertical; box-sizing: border-box; + background: #fff; margin-top: 4px; + } `; diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js index 937d379..1782293 100644 --- a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js @@ -89,14 +89,14 @@ function _renderColumnCard(host, col, gIdx, cIdx, totalCols) { @dragover=${(e) => { if (e.dataTransfer.types.includes('application/col-drag')) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; e.currentTarget.classList.add('col-drag-over'); } }} @dragleave=${(e) => { e.currentTarget.classList.remove('col-drag-over'); }} @drop=${(e) => { - e.preventDefault(); e.currentTarget.classList.remove('col-drag-over'); - try { - const from = JSON.parse(e.dataTransfer.getData('application/col-drag')); - if (from.gIdx === gIdx && from.cIdx !== cIdx) { - const cols = host._editForm.groups[gIdx].columns; const [moved] = cols.splice(from.cIdx, 1); cols.splice(cIdx, 0, moved); host.requestUpdate(); - } else if (from.gIdx !== gIdx) { host._moveColumnTo(from.gIdx, from.cIdx, gIdx); } - } catch {} - }}> + e.preventDefault(); e.currentTarget.classList.remove('col-drag-over'); + try { + const from = JSON.parse(e.dataTransfer.getData('application/col-drag')); + if (from.gIdx === gIdx && from.cIdx !== cIdx) { + const cols = host._editForm.groups[gIdx].columns; const [moved] = cols.splice(from.cIdx, 1); cols.splice(cIdx, 0, moved); host.requestUpdate(); + } else if (from.gIdx !== gIdx) { host._moveColumnTo(from.gIdx, from.cIdx, gIdx); } + } catch { } + }}>
Col ${cIdx + 1} @@ -106,7 +106,7 @@ function _renderColumnCard(host, col, gIdx, cIdx, totalCols) {
-
`; @@ -271,18 +263,17 @@ function _renderMoveToInDialog(host, loc) { }); if (destinations.length === 0) return nothing; return html` -
- +
`; + `; } // ── Shared helpers ── @@ -336,23 +327,27 @@ function _renderColumnSettings(host, f) {
${allFieldNames.length === 0 ? html`
No fields yet.
` : nothing} ${orderedNames.map((name, idx) => { - const isVisible = f.visibleColumns == null ? true : f.visibleColumns.includes(name); - return html``; })} + if (!f.visibleColumns) f.visibleColumns = [...allFieldNames]; + if (e.target.checked) { if (!f.visibleColumns.includes(name)) f.visibleColumns.push(name); } + else { f.visibleColumns = f.visibleColumns.filter(c => c !== name); } host.requestUpdate(); + }} /> + ${name}`; + })}
`; } @@ -382,14 +377,14 @@ function _renderTypePicker(host) { function _renderTypeAttributes(host, field, idx, loc) { const t = field.type; if (!field.attributes) field.attributes = {}; const a = field.attributes, s = (k, v) => { field.attributes[k] = v; host.requestUpdate(); }; - if (t === 'number') return html`
`; - if (t === 'date') return html`
`; - if (t === 'time') return html`
`; - if (t === 'textarea') return html`
`; - if (t === 'file') return html`
`; - if (t === 'range') return html`
`; - if (t === 'accept') return html`
`; - if (t === 'step') return html`
`; + if (t === 'number') return html`
`; + if (t === 'date') return html`
`; + if (t === 'time') return html`
`; + if (t === 'textarea') return html`
`; + if (t === 'file') return html`
`; + if (t === 'range') return html`
`; + if (t === 'accept') return html`
`; + if (t === 'step') return html`
`; return nothing; } From ae170e4d71989fd5e12462d111345c0eb1c67017 Mon Sep 17 00:00:00 2001 From: Nguyen Thien Tu <15050790+thientu995@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:30:24 +0700 Subject: [PATCH 6/9] Update UI --- .../App_Plugins/simple-form/views/editor-view.js | 13 ++++++++----- .../App_Plugins/simple-form/views/editor-view.js | 13 ++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js index 1782293..49f5a01 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/wwwroot/App_Plugins/simple-form/views/editor-view.js @@ -297,12 +297,15 @@ function _renderEmbedSettings(host, f) { function _renderGeneralSettings(host, f) { return html`
-

General Settings

+
+

General Settings + +

+
- diff --git a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js index 1782293..49f5a01 100644 --- a/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js +++ b/uTPro/Project/uTPro.Project.Web/wwwroot/App_Plugins/simple-form/views/editor-view.js @@ -297,12 +297,15 @@ function _renderEmbedSettings(host, f) { function _renderGeneralSettings(host, f) { return html`
-

General Settings

+
+

General Settings + +

+
- From c4515fde50987e1e5aad9e7c3d92dcaf4268b4ba Mon Sep 17 00:00:00 2001 From: Nguyen Thien Tu <15050790+thientu995@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:54:24 +0700 Subject: [PATCH 7/9] Update control upgrade and document --- .../Helpers/FieldPartialResolver.cs | 40 ++ .../Helpers/SimpleFormAssets.cs | 56 +++ .../Helpers/SimpleFormHtmlHelper.cs | 102 +++++ .../Migrations/SimpleFormMigration.cs | 234 +++-------- .../uTPro.Feature.SimpleForm/README.md | 377 ++++++++++++++++++ .../ViewComponents/SimpleFormViewComponent.cs | 23 +- .../Views/Partials/SimpleForm/Default.cshtml | 50 +-- .../SimpleForm/Fields/_Default.cshtml | 78 ++-- .../Partials/SimpleForm/Fields/accept.cshtml | 21 +- .../SimpleForm/Fields/checkbox.cshtml | 17 +- .../Partials/SimpleForm/Fields/color.cshtml | 14 +- .../Partials/SimpleForm/Fields/div.cshtml | 7 +- .../Partials/SimpleForm/Fields/radio.cshtml | 22 +- .../Partials/SimpleForm/Fields/range.cshtml | 27 +- .../Partials/SimpleForm/Fields/select.cshtml | 16 +- .../Partials/SimpleForm/Fields/step.cshtml | 4 +- .../SimpleForm/Fields/textarea.cshtml | 34 +- .../Partials/SimpleForm/Fields/time.cshtml | 25 +- .../uTPro.Feature.SimpleForm.csproj | 39 +- .../Views/Partials/SimpleForm/Default.cshtml | 50 +-- .../SimpleForm/Fields/_Default.cshtml | 78 ++-- .../Partials/SimpleForm/Fields/accept.cshtml | 21 +- .../SimpleForm/Fields/checkbox.cshtml | 17 +- .../Partials/SimpleForm/Fields/color.cshtml | 14 +- .../Partials/SimpleForm/Fields/div.cshtml | 7 +- .../Partials/SimpleForm/Fields/radio.cshtml | 22 +- .../Partials/SimpleForm/Fields/range.cshtml | 27 +- .../Partials/SimpleForm/Fields/select.cshtml | 16 +- .../Partials/SimpleForm/Fields/step.cshtml | 4 +- .../SimpleForm/Fields/textarea.cshtml | 34 +- .../Partials/SimpleForm/Fields/time.cshtml | 25 +- 31 files changed, 938 insertions(+), 563 deletions(-) create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/FieldPartialResolver.cs create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/SimpleFormAssets.cs create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/SimpleFormHtmlHelper.cs create mode 100644 uTPro/Feature/uTPro.Feature.SimpleForm/README.md diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/FieldPartialResolver.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/FieldPartialResolver.cs new file mode 100644 index 0000000..cb9fbdb --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/FieldPartialResolver.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.Extensions.DependencyInjection; + +namespace uTPro.Feature.SimpleForm.Helpers; + +/// +/// Resolves the correct partial view path for a given field type. +/// Looks for ~/Views/Partials/SimpleForm/Fields/{type}.cshtml first, +/// then falls back to ~/Views/Partials/SimpleForm/Fields/_Default.cshtml. +/// +/// HOW TO ADD A NEW FIELD TYPE: +/// 1. Create a new file: Views/Partials/SimpleForm/Fields/{yourType}.cshtml +/// 2. Use @model FormFieldViewModel +/// 3. Use FieldHelper for consistent label/error rendering +/// 4. Register the type in SimpleFormApiController.FieldTypes() if you want it in the backoffice picker +/// That's it — the resolver picks it up automatically. +/// +public static class FieldPartialResolver +{ + private const string FieldsBasePath = "~/Views/Partials/SimpleForm/Fields/"; + private const string FallbackPartial = FieldsBasePath + "_Default.cshtml"; + + /// + /// Returns the partial view path for the given field type. + /// + public static string Resolve(string fieldType, ViewContext viewContext) + { + var customPath = FieldsBasePath + fieldType + ".cshtml"; + + var viewEngine = viewContext.HttpContext.RequestServices + .GetRequiredService(); + + var exists = viewEngine.GetView(null, customPath, false).Success + || viewEngine.FindView(viewContext, customPath, false).Success; + + return exists ? customPath : FallbackPartial; + } +} diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/SimpleFormAssets.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/SimpleFormAssets.cs new file mode 100644 index 0000000..6eed72b --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/SimpleFormAssets.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; + +namespace uTPro.Feature.SimpleForm.Helpers; + +/// +/// Resolves the correct base path for SimpleForm static assets (CSS, JS). +/// +/// When consumed via NuGet (RCL), assets are served at: +/// ~/_content/uTPro.Feature.SimpleForm/uTPro/simple-form/... +/// +/// When consumed via project reference with the copy target, assets are at: +/// ~/uTPro/simple-form/... +/// +/// This helper checks which path exists at runtime and returns the correct one. +/// +public static class SimpleFormAssets +{ + private const string PackageId = "uTPro.Feature.SimpleForm"; + private const string LocalBase = "/uTPro/simple-form"; + private const string RclBase = "/_content/" + PackageId + "/uTPro/simple-form"; + + private static string? _resolvedBase; + + /// Path to simple-form.css + public static string Css => GetBase() + "/css/simple-form.css"; + + /// Path to simple-form.js + public static string Js => GetBase() + "/js/simple-form.js"; + + private static string GetBase() + { + // Cache after first resolution + return _resolvedBase ??= LocalBase; + } + + /// + /// Call once at startup (or first request) to detect whether assets are served + /// from the local wwwroot or from the RCL _content path. + /// + public static void Resolve(IWebHostEnvironment env) + { + if (_resolvedBase != null) return; + + // Check if local file exists (project reference / copy target scenario) + var localPath = Path.Combine(env.WebRootPath ?? "", "uTPro", "simple-form", "css", "simple-form.css"); + if (File.Exists(localPath)) + { + _resolvedBase = LocalBase; + return; + } + + // Otherwise assume RCL content path + _resolvedBase = RclBase; + } +} diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/SimpleFormHtmlHelper.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/SimpleFormHtmlHelper.cs new file mode 100644 index 0000000..ae58810 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/SimpleFormHtmlHelper.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Umbraco.Cms.Core.Dictionary; +using uTPro.Feature.SimpleForm.Models; + +namespace uTPro.Feature.SimpleForm.Helpers; + +/// +/// Razor helper methods shared across all SimpleForm field partials. +/// Eliminates boilerplate for labels, error spans, field IDs, and validation messages. +/// +/// Usage in a field partial: +/// @using uTPro.Feature.SimpleForm.Helpers +/// @{ var h = new FieldHelper(Model, ViewData); } +/// @h.Label() +/// <input type="text" id="@h.FieldId" name="@h.Name" ... /> +/// @h.Error() +/// +public class FieldHelper +{ + public FormFieldViewModel Field { get; } + public string FormId { get; } + public string FieldId { get; } + public string Name => Field.Name; + public string ResolvedValidationMessage { get; } + + public FieldHelper(FormFieldViewModel field, ViewDataDictionary viewData) + { + Field = field; + FormId = viewData["FormId"] as string ?? "sf"; + FieldId = FormId + "-" + field.Name; + ResolvedValidationMessage = ResolveValidationMessage(field, viewData); + } + + /// Renders a <label> with optional required marker. + public IHtmlContent Label(string? forId = null) + { + var id = forId ?? FieldId; + var html = $""; + return new HtmlString(html); + } + + /// Renders a <label> without the "for" attribute (for checkbox/radio groups). + public IHtmlContent LabelNoFor() + { + var html = $""; + return new HtmlString(html); + } + + /// Renders the error <span> for client-side validation. + public IHtmlContent Error() + => new HtmlString($""); + + /// Returns "required" attribute string or empty. + public string RequiredAttr() => Field.Required ? "required" : ""; + + /// Returns pattern attribute string or empty. + public string PatternAttr() + => string.IsNullOrEmpty(Field.Validation) ? "" : $"pattern=\"{Encode(Field.Validation)}\""; + + /// Returns data-msg attribute string. + public string DataMsgAttr() + => $"data-msg=\"{Encode(ResolvedValidationMessage)}\""; + + /// Gets an attribute value from Field.Attributes with a fallback default. + public string Attr(string key, string fallback = "") + => Field.Attributes?.GetValueOrDefault(key) ?? fallback; + + /// Returns a conditional HTML attribute string, or empty if value is blank. + public string OptionalAttr(string attrName, string value) + => string.IsNullOrEmpty(value) ? "" : $"{attrName}=\"{Encode(value)}\""; + + private static string ResolveValidationMessage(FormFieldViewModel field, ViewDataDictionary viewData) + { + var msg = field.ValidationMessage; + if (string.IsNullOrEmpty(msg)) return ""; + + // Support dictionary keys wrapped in {{ }} + if (msg.StartsWith("{{") && msg.EndsWith("}}")) + { + var dictKey = msg[2..^2].Trim(); + var cultureDictionary = viewData["CultureDictionary"] as ICultureDictionary; + if (cultureDictionary != null) + { + var translated = cultureDictionary[dictKey]; + if (!string.IsNullOrEmpty(translated)) + return translated; + } + } + + return msg; + } + + private static string Encode(string? value) + => System.Net.WebUtility.HtmlEncode(value ?? ""); +} diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs b/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs index 369fa25..dfe9a9b 100644 --- a/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs @@ -9,14 +9,18 @@ namespace uTPro.Feature.SimpleForm.Migrations; -// ── v1: Create all tables with final schema ── - -public class CreateSimpleFormTablesV1 : MigrationBase +/// +/// Creates the SimpleForm tables and seeds the default "Contact Us" form. +/// This is a single, clean migration that produces the final schema in one step. +/// +public class InitSimpleForm : MigrationBase { - public CreateSimpleFormTablesV1(IMigrationContext context) : base(context) { } + public InitSimpleForm(IMigrationContext context) : base(context) { } protected override void Migrate() { + // ── Tables ── + if (TableExists("utpro_SimpleFormEntry")) Delete.Table("utpro_SimpleFormEntry").Do(); if (TableExists("utpro_SimpleForm")) @@ -27,6 +31,7 @@ protected override void Migrate() .WithColumn("Name").AsString(255).NotNullable() .WithColumn("Alias").AsString(255).NotNullable().Unique("IX_utpro_SimpleForm_Alias") .WithColumn("FieldsJson").AsCustom("NTEXT").Nullable() + .WithColumn("GroupsJson").AsCustom("NTEXT").Nullable() .WithColumn("SuccessMessage").AsString(1000).Nullable() .WithColumn("RedirectUrl").AsString(500).Nullable() .WithColumn("EmailTo").AsString(500).Nullable() @@ -48,177 +53,11 @@ protected override void Migrate() .WithColumn("UserAgent").AsString(500).Nullable() .WithColumn("CreatedUtc").AsDateTime().NotNullable() .Do(); - } -} -// ── v1: Seed default Contact Us form ── + // ── Seed: Contact Us form (final groups → columns → fields format) ── -public class SeedContactFormV1 : MigrationBase -{ - public SeedContactFormV1(IMigrationContext context) : base(context) { } - - protected override void Migrate() - { var now = DateTime.UtcNow; - var fieldsJson = @"[ - {""id"":""f1"",""type"":""text"",""label"":""Name"",""name"":""name"",""placeholder"":""Name"",""required"":true,""sortOrder"":0,""validationMessage"":""Please enter your name""}, - {""id"":""f2"",""type"":""email"",""label"":""Email"",""name"":""email"",""placeholder"":""Email"",""required"":true,""sortOrder"":1,""validationMessage"":""Please enter a valid email""}, - {""id"":""f3"",""type"":""textarea"",""label"":""Message"",""name"":""message"",""placeholder"":""Message"",""required"":true,""sortOrder"":2,""validationMessage"":""Please enter your message""} -]"; - - var existing = Context.Database.ExecuteScalar( - "SELECT COUNT(*) FROM utpro_SimpleForm WHERE Alias = @0", "contact-us"); - - if (existing == 0) - { - Context.Database.Execute(@" - INSERT INTO utpro_SimpleForm - (Name, Alias, FieldsJson, SuccessMessage, RedirectUrl, EmailTo, EmailSubject, - StoreEntries, IsEnabled, VisibleColumnsJson, EnableRenderApi, EnableEntriesApi, CreatedUtc, UpdatedUtc) - VALUES (@0, @1, @2, @3, @4, @5, @6, @7, @8, @9, @10, @11, @12, @13)", - "Contact Us", - "contact-us", - fieldsJson, - "Thank you for contacting us! We will get back to you soon.", - "", - "", - "New Contact Form Entry", - true, // StoreEntries - true, // IsEnabled - null, // VisibleColumnsJson - false, // EnableRenderApi - false, // EnableEntriesApi - now, - now); - } - } -} - -// ── v2: Add GroupsJson column ── - -public class AddGroupsJsonColumnV2 : MigrationBase -{ - public AddGroupsJsonColumnV2(IMigrationContext context) : base(context) { } - - protected override void Migrate() - { - if (!ColumnExists("utpro_SimpleForm", "GroupsJson")) - { - Alter.Table("utpro_SimpleForm") - .AddColumn("GroupsJson").AsCustom("NTEXT").Nullable() - .Do(); - } - } -} - -// ── v2: Migrate existing FieldsJson into GroupsJson ── - -public class MigrateFieldsToGroupsV2 : MigrationBase -{ - public MigrateFieldsToGroupsV2(IMigrationContext context) : base(context) { } - - protected override void Migrate() - { - // Find all forms that have fields but no groups yet - // NTEXT columns cannot use = or <> operators directly, so use DATALENGTH / IS NULL checks - var rows = Context.Database.Fetch( - "SELECT Id, CAST(FieldsJson AS NVARCHAR(MAX)) AS FieldsJson FROM utpro_SimpleForm WHERE FieldsJson IS NOT NULL AND DATALENGTH(FieldsJson) > 4 AND (GroupsJson IS NULL OR DATALENGTH(GroupsJson) <= 4)"); - foreach (var row in rows) - { - int id = (int)row.Id; - string fieldsJson = (string)row.FieldsJson; - - // Skip if fieldsJson is empty array - if (string.IsNullOrWhiteSpace(fieldsJson) || fieldsJson.Trim() == "[]") - continue; - - // Wrap existing fields into a single default group with 1 column (width=12) - var groupsJson = @"[{""id"":""g_default"",""name"":"""",""cssClass"":"""",""columns"":[{""id"":""c_default"",""width"":12,""fields"":" + fieldsJson + @"}],""sortOrder"":0}]"; - - Context.Database.Execute( - "UPDATE utpro_SimpleForm SET GroupsJson = CAST(@0 AS NTEXT), FieldsJson = CAST('[]' AS NTEXT) WHERE Id = @1", - groupsJson, id); - } - } -} - -// ── v3: Convert old flat group.fields format to new group.columns[].fields[] format ── - -public class ConvertGroupsToColumnFormatV3 : MigrationBase -{ - public ConvertGroupsToColumnFormatV3(IMigrationContext context) : base(context) { } - - protected override void Migrate() - { - // Find forms that have GroupsJson with old format (has "fields" at group level instead of "columns") - var rows = Context.Database.Fetch( - "SELECT Id, CAST(GroupsJson AS NVARCHAR(MAX)) AS GroupsJson FROM utpro_SimpleForm WHERE GroupsJson IS NOT NULL AND DATALENGTH(GroupsJson) > 4"); - - foreach (var row in rows) - { - int id = (int)row.Id; - string json = (string)row.GroupsJson; - if (string.IsNullOrWhiteSpace(json) || json.Trim() == "[]") continue; - - // Check if it's old format: contains "fields" at group level but no "columns" array with objects - // Old format: [{"fields":[...],"columns":1,...}] - // New format: [{"columns":[{"width":12,"fields":[...]}],...}] - // Simple heuristic: if JSON contains "\"columns\":" followed by a number, it's old format - if (!json.Contains("\"columns\":[{")) // new format has "columns":[{...}] - { - try - { - var opts = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }; - var oldGroups = System.Text.Json.JsonSerializer.Deserialize(json); - var newGroups = new System.Collections.Generic.List(); - - foreach (var g in oldGroups.EnumerateArray()) - { - var gId = g.TryGetProperty("id", out var idProp) ? idProp.GetString() : System.Guid.NewGuid().ToString("N"); - var gName = g.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : ""; - var gCss = g.TryGetProperty("cssClass", out var cssProp) ? cssProp.GetString() : ""; - var gSort = g.TryGetProperty("sortOrder", out var sortProp) ? sortProp.GetInt32() : 0; - - // Get old fields array - var fieldsJson = "[]"; - if (g.TryGetProperty("fields", out var fieldsProp) && fieldsProp.ValueKind == System.Text.Json.JsonValueKind.Array) - { - fieldsJson = fieldsProp.GetRawText(); - } - - newGroups.Add(new - { - id = gId, - name = gName, - cssClass = gCss, - columns = new[] { new { id = "c_" + gId, width = 12, fields = System.Text.Json.JsonSerializer.Deserialize(fieldsJson) } }, - sortOrder = gSort - }); - } - - var newJson = System.Text.Json.JsonSerializer.Serialize(newGroups, opts); - Context.Database.Execute( - "UPDATE utpro_SimpleForm SET GroupsJson = CAST(@0 AS NTEXT) WHERE Id = @1", - newJson, id); - } - catch - { - // Skip if JSON parsing fails — don't break migration - } - } - } - } -} - -// ── v4: Update default Contact Us form to use proper 2-group layout ── - -public class UpdateContactFormLayoutV4 : MigrationBase -{ - public UpdateContactFormLayoutV4(IMigrationContext context) : base(context) { } - - protected override void Migrate() - { var groupsJson = @"[ { ""id"":""g1"",""name"":"""",""cssClass"":"""",""sortOrder"":0, @@ -241,9 +80,32 @@ protected override void Migrate() } ]"; - Context.Database.Execute( - "UPDATE utpro_SimpleForm SET GroupsJson = CAST(@0 AS NTEXT), FieldsJson = CAST('[]' AS NTEXT) WHERE Alias = @1", - groupsJson, "contact-us"); + Context.Database.Execute(@" + INSERT INTO utpro_SimpleForm + (Name, Alias, FieldsJson, GroupsJson, + SuccessMessage, RedirectUrl, EmailTo, EmailSubject, + StoreEntries, IsEnabled, VisibleColumnsJson, + EnableRenderApi, EnableEntriesApi, CreatedUtc, UpdatedUtc) + VALUES + (@0, @1, @2, @3, + @4, @5, @6, @7, + @8, @9, @10, + @11, @12, @13, @14)", + "Contact Us", // Name + "contact-us", // Alias + "[]", // FieldsJson (legacy, empty) + groupsJson, // GroupsJson + "Thank you for contacting us! We will get back to you soon.", // SuccessMessage + "", // RedirectUrl + "", // EmailTo + "New Contact Form Entry", // EmailSubject + true, // StoreEntries + true, // IsEnabled + null, // VisibleColumnsJson + false, // EnableRenderApi + false, // EnableEntriesApi + now, // CreatedUtc + now); // UpdatedUtc } } @@ -253,13 +115,15 @@ public class RunSimpleFormMigration : IComposer { public void Compose(IUmbracoBuilder builder) { - builder.AddNotificationAsyncHandler(); } } public class SimpleFormMigrationHandler - : Umbraco.Cms.Core.Events.INotificationAsyncHandler + : Umbraco.Cms.Core.Events.INotificationAsyncHandler< + Umbraco.Cms.Core.Notifications.UmbracoApplicationStartedNotification> { private readonly IMigrationPlanExecutor _migrationPlanExecutor; private readonly ICoreScopeProvider _coreScopeProvider; @@ -278,22 +142,16 @@ public SimpleFormMigrationHandler( _runtimeState = runtimeState; } - public Task HandleAsync(Umbraco.Cms.Core.Notifications.UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken) + public Task HandleAsync( + Umbraco.Cms.Core.Notifications.UmbracoApplicationStartedNotification notification, + CancellationToken cancellationToken) { - if (_runtimeState.Level < RuntimeLevel.Run) return Task.CompletedTask; + if (_runtimeState.Level < RuntimeLevel.Run) + return Task.CompletedTask; var plan = new MigrationPlan("uTPro.SimpleForm"); plan.From(string.Empty) - .To("simpleform-v1-001-tables") - .To("simpleform-v1-002-seed") - .To("simpleform-v2-001-groups") - .To("simpleform-v2-003-migrate-fields-fix") - .To("simpleform-v3-001-column-format") - .To("simpleform-v4-001-contact-layout"); - - // Handle failed v2-002 state - plan.From("simpleform-v2-002-migrate-fields") - .To("simpleform-v2-003-migrate-fields-fix"); + .To("simpleform-init"); var upgrader = new Upgrader(plan); upgrader.Execute(_migrationPlanExecutor, _coreScopeProvider, _keyValueService); diff --git a/uTPro/Feature/uTPro.Feature.SimpleForm/README.md b/uTPro/Feature/uTPro.Feature.SimpleForm/README.md new file mode 100644 index 0000000..132e3b0 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/README.md @@ -0,0 +1,377 @@ +# SimpleForm — Lightweight Form Builder for Umbraco + +SimpleForm lets you create and manage dynamic forms from the Umbraco backoffice — no code required for everyday use. For developers, adding a custom field type takes just two steps. + +--- + +## Getting Started + +### Install via NuGet + +```bash +dotnet add package uTPro.Feature.SimpleForm +``` + +### Or add as a project reference + +```xml + +``` + +That's it. On first run, SimpleForm automatically creates its database tables and seeds a sample "Contact Us" form. + +--- + +## Rendering a Form + +Drop this line into any Razor view or block component: + +```razor +@await Component.InvokeAsync("SimpleForm", new { alias = "contact-us" }) +``` + +The `alias` matches the form you created in the backoffice (Settings → Simple Form). + +### Optional Parameters + +```razor +@await Component.InvokeAsync("SimpleForm", new { + alias = "contact-us", + template = "MyLayout", // use a custom Razor template + cssClass = "my-form", // add a CSS class to the
tag + submitBtnText = "Send", // change the submit button text + showReset = true, // show or hide the reset button + resetBtnText = "Clear" // change the reset button text +}) +``` + +### How Template Resolution Works + +SimpleForm looks for a matching view in this order: + +1. `Views/Partials/SimpleForm/{template}.cshtml` — if you passed a `template` parameter +2. `Views/Partials/SimpleForm/{alias}.cshtml` — a view named after the form alias +3. `Views/Partials/SimpleForm/Default.cshtml` — the built-in default + +To customize the layout for a specific form, just create a file matching its alias. No config changes needed. + +--- + +## Creating Forms in the Backoffice + +1. Go to **Settings → Simple Form** in the Umbraco backoffice +2. Click **New Form** +3. Give it a **Name** and **Alias** (the alias is what you use in code) +4. Add **Groups** to organize your fields into sections +5. Inside each group, add **Columns** (based on a 12-column grid) and drop **Fields** into them +6. Configure each field: type, label, placeholder, validation, etc. +7. Set up the **Success Message**, optional **Redirect URL**, and **Email Notification** +8. Save + +Your form is ready to render on the frontend. + +--- + +## Built-in Field Types + +SimpleForm ships with 19 field types out of the box: + +| Type | Description | Has its own partial? | +|---|---|---| +| `text` | Single-line text input | No (uses `_Default.cshtml`) | +| `email` | Email input | No | +| `tel` | Phone number | No | +| `number` | Numeric input | No | +| `url` | URL input | No | +| `password` | Password input (auto-encrypted at rest) | No | +| `date` | Date picker | No | +| `file` | File upload | No | +| `textarea` | Multi-line text | Yes | +| `select` | Dropdown menu | Yes | +| `checkbox` | Single or multi-checkbox | Yes | +| `radio` | Radio button group | Yes | +| `hidden` | Hidden field | Yes | +| `accept` | Terms & conditions checkbox with link | Yes | +| `range` | Slider with min/max/step | Yes | +| `color` | Color picker | Yes | +| `time` | Time picker with min/max | Yes | +| `div` | HTML content block (not a form input) | Yes | +| `step` | Visual step divider | Yes | + +Types without a dedicated partial fall back to `_Default.cshtml`, which renders a standard `` element. + +--- + +## Adding a Custom Field Type + +This is the part most developers care about. It takes two steps. + +### Step 1 — Create a Razor partial + +Create a `.cshtml` file named after your field type: + +``` +Views/Partials/SimpleForm/Fields/{yourType}.cshtml +``` + +Here's a minimal template: + +```razor +@using uTPro.Feature.SimpleForm.Helpers +@model uTPro.Feature.SimpleForm.Models.FormFieldViewModel +@{ var h = new FieldHelper(Model, ViewData); } + +@h.Label() + +@h.Error() +``` + +SimpleForm auto-detects the new file. No changes to `Default.cshtml` or any config. + +### Step 2 — Register it in the backoffice picker + +Open `Controllers/SimpleFormApiController.cs` and add one line to the `FieldTypes()` method: + +```csharp +new { type = "yourType", label = "Your Type Label" }, +``` + +Build. Your new field type now appears in the backoffice form builder. + +--- + +## FieldHelper — The Toolkit for Field Partials + +Every field partial can use `FieldHelper` to avoid repetitive HTML boilerplate: + +```razor +@{ var h = new FieldHelper(Model, ViewData); } +``` + +| What you call | What it renders | +|---|---| +| `h.FieldId` | Unique HTML id like `sf-contact-us-email` | +| `h.Name` | The field name for form submission | +| `h.Label()` | `