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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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; }
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public static class FieldPartialResolver
{
private const string FieldsBasePath = "~/Views/Partials/SimpleForm/Fields/";
private const string FallbackPartial = FieldsBasePath + "_Default.cshtml";

/// <summary>
/// Returns the partial view path for the given field type.
/// </summary>
public static string Resolve(string fieldType, ViewContext viewContext)
{
var customPath = FieldsBasePath + fieldType + ".cshtml";

var viewEngine = viewContext.HttpContext.RequestServices
.GetRequiredService<ICompositeViewEngine>();

var exists = viewEngine.GetView(null, customPath, false).Success
|| viewEngine.FindView(viewContext, customPath, false).Success;

return exists ? customPath : FallbackPartial;
}
}
56 changes: 56 additions & 0 deletions uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/SimpleFormAssets.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;

namespace uTPro.Feature.SimpleForm.Helpers;

/// <summary>
/// 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.
/// </summary>
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;

/// <summary>Path to simple-form.css</summary>
public static string Css => GetBase() + "/css/simple-form.css";

/// <summary>Path to simple-form.js</summary>
public static string Js => GetBase() + "/js/simple-form.js";

private static string GetBase()
{
// Cache after first resolution
return _resolvedBase ??= LocalBase;
}

/// <summary>
/// Call once at startup (or first request) to detect whether assets are served
/// from the local wwwroot or from the RCL _content path.
/// </summary>
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;
}
}
102 changes: 102 additions & 0 deletions uTPro/Feature/uTPro.Feature.SimpleForm/Helpers/SimpleFormHtmlHelper.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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()
/// &lt;input type="text" id="@h.FieldId" name="@h.Name" ... /&gt;
/// @h.Error()
/// </summary>
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);
}

/// <summary>Renders a &lt;label&gt; with optional required marker.</summary>
public IHtmlContent Label(string? forId = null)
{
var id = forId ?? FieldId;
var html = $"<label for=\"{Encode(id)}\">{Encode(Field.Label)}";
if (Field.Required)
html += " <span class=\"sf-required\">*</span>";
html += "</label>";
return new HtmlString(html);
}

/// <summary>Renders a &lt;label&gt; without the "for" attribute (for checkbox/radio groups).</summary>
public IHtmlContent LabelNoFor()
{
var html = $"<label>{Encode(Field.Label)}";
if (Field.Required)
html += " <span class=\"sf-required\">*</span>";
html += "</label>";
return new HtmlString(html);
}

/// <summary>Renders the error &lt;span&gt; for client-side validation.</summary>
public IHtmlContent Error()
=> new HtmlString($"<span class=\"sf-error\" data-for=\"{Encode(Name)}\"></span>");

/// <summary>Returns "required" attribute string or empty.</summary>
public string RequiredAttr() => Field.Required ? "required" : "";

/// <summary>Returns pattern attribute string or empty.</summary>
public string PatternAttr()
=> string.IsNullOrEmpty(Field.Validation) ? "" : $"pattern=\"{Encode(Field.Validation)}\"";

/// <summary>Returns data-msg attribute string.</summary>
public string DataMsgAttr()
=> $"data-msg=\"{Encode(ResolvedValidationMessage)}\"";

/// <summary>Gets an attribute value from Field.Attributes with a fallback default.</summary>
public string Attr(string key, string fallback = "")
=> Field.Attributes?.GetValueOrDefault(key) ?? fallback;

/// <summary>Returns a conditional HTML attribute string, or empty if value is blank.</summary>
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 ?? "");
}
Loading
Loading