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..5affdde --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormApiController.cs @@ -0,0 +1,128 @@ +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; + +namespace uTPro.Feature.SimpleForm.Controllers; + +[VersionedApiBackOfficeRoute("utpro/simple-form")] +[ApiExplorerSettings(GroupName = "uTPro Simple Form")] +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()); + + [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) + { + 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 }); + } + + [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 }); + } + + // ── 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-entry")] + public IActionResult DeleteEntry([FromBody] DeleteFormRequest request) + { + 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" }, + 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 = "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 new file mode 100644 index 0000000..7e10a87 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Controllers/SimpleFormSubmitController.cs @@ -0,0 +1,40 @@ +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" }); + 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/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 new file mode 100644 index 0000000..dfe9a9b --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Migrations/SimpleFormMigration.cs @@ -0,0 +1,161 @@ +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; + +/// +/// 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 InitSimpleForm(IMigrationContext context) : base(context) { } + + protected override void Migrate() + { + // ── Tables ── + + 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("GroupsJson").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(); + + // ── Seed: Contact Us form (final groups → columns → fields format) ── + + var now = DateTime.UtcNow; + + 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(@" + 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 + } +} + +// ── Migration runner ── + +public class RunSimpleFormMigration : IComposer +{ + public void Compose(IUmbracoBuilder builder) + { + builder.AddNotificationAsyncHandler< + Umbraco.Cms.Core.Notifications.UmbracoApplicationStartedNotification, + SimpleFormMigrationHandler>(); + } +} + +public class SimpleFormMigrationHandler + : Umbraco.Cms.Core.Events.INotificationAsyncHandler< + Umbraco.Cms.Core.Notifications.UmbracoApplicationStartedNotification> +{ + 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-init"); + + var upgrader = new Upgrader(plan); + upgrader.Execute(_migrationPlanExecutor, _coreScopeProvider, _keyValueService); + + return Task.CompletedTask; + } +} 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..db42b96 --- /dev/null +++ b/uTPro/Feature/uTPro.Feature.SimpleForm/Models/FormModels.cs @@ -0,0 +1,178 @@ +using System.Text.Json.Serialization; +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? GroupsJson { get; set; } + public string? SuccessMessage { get; set; } + public string? RedirectUrl { get; set; } + public string? EmailTo { get; set; } + public string? EmailSubject { get; set; } + 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_SimpleFormEntry")] +[PrimaryKey("Id", AutoIncrement = true)] +public class SimpleFormEntryDto +{ + 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; } = []; + /// 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; } + public string? EmailSubject { get; set; } + 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; } +} + +/// +/// 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"); + 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; } + /// 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; } + /// 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; } +} + +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 List Groups { get; set; } = []; + public string? SuccessMessage { get; set; } + public string? RedirectUrl { get; set; } + public string? EmailTo { get; set; } + public string? EmailSubject { get; set; } + 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 +{ + 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 EntryViewModel +{ + 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 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 +{ + public IEnumerable Items { get; set; } = []; + public long Total { get; set; } +} 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()` | `