Skip to content

Commit

Permalink
Generic Forms (btcpayserver#4561)
Browse files Browse the repository at this point in the history
* Custom Forms

* Update BTCPayServer.Data/Migrations/20230125085242_AddForms.cs

* Cleanups

* Explain public form

* Add store branding

* Add form name to POS form

* add tests

* fix migration

* Minor cleanups

* Code improvements

* Add form validation

Closes btcpayserver#4317.

* Adapt form validation for Bootstrap 5

* update logic for forms

* pr changes

* Minor code cleanup

* Remove unused parameters

* Refactor Form data handling to avoid O(n3) issues

* Rename Hidden to Constant

* Pre-populate FormView from the query string params

* Fix test

---------

Co-authored-by: d11n <[email protected]>
Co-authored-by: nicolas.dorier <[email protected]>
  • Loading branch information
3 people authored Feb 20, 2023
1 parent 60f84d5 commit bbbaacc
Show file tree
Hide file tree
Showing 30 changed files with 1,157 additions and 337 deletions.
10 changes: 5 additions & 5 deletions BTCPayServer.Abstractions/Form/Field.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class Field
{
public static Field Create(string label, string name, string value, bool required, string helpText, string type = "text")
{
return new Field()
return new Field
{
Label = label,
Name = name,
Expand All @@ -26,14 +26,14 @@ public static Field Create(string label, string name, string value, bool require
// The name of the HTML5 node. Should be used as the key for the posted data.
public string Name;

public bool Hidden;
public bool Constant;

// HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options).
public string Type;

public static Field CreateFieldset()
{
return new Field() { Type = "fieldset" };
return new Field { Type = "fieldset" };
}

// The value field is what is currently in the DB or what the user entered, but possibly not saved yet due to validation errors.
Expand All @@ -52,10 +52,10 @@ public static Field CreateFieldset()
public string HelpText;

[JsonExtensionData] public IDictionary<string, JToken> AdditionalData { get; set; }
public List<Field> Fields { get; set; } = new();
public List<Field> Fields { get; set; } = new ();

// The field is considered "valid" if there are no validation errors
public List<string> ValidationErrors = new List<string>();
public List<string> ValidationErrors = new ();

public virtual bool IsValid()
{
Expand Down
162 changes: 82 additions & 80 deletions BTCPayServer.Abstractions/Form/Form.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json.Linq;
using Npgsql.Internal.TypeHandlers.GeometricHandlers;

namespace BTCPayServer.Abstractions.Form;

Expand All @@ -20,6 +22,7 @@ public override string ToString()
return JObject.FromObject(this, CamelCaseSerializerSettings.Serializer).ToString(Newtonsoft.Json.Formatting.Indented);
}
#nullable restore

// Messages to be shown at the top of the form indicating user feedback like "Saved successfully" or "Please change X because of Y." or a warning, etc...
public List<AlertMessage> TopMessages { get; set; } = new();

Expand All @@ -32,126 +35,125 @@ public bool IsValid()
return Fields.Select(f => f.IsValid()).All(o => o);
}

public Field GetFieldByName(string name)
public Field GetFieldByFullName(string fullName)
{
return GetFieldByName(name, Fields, null);
foreach (var f in GetAllFields())
{
if (f.FullName == fullName)
return f.Field;
}
return null;
}

private static Field GetFieldByName(string name, List<Field> fields, string prefix)
public IEnumerable<(string FullName, List<string> Path, Field Field)> GetAllFields()
{
prefix ??= string.Empty;
foreach (var field in fields)
HashSet<string> nameReturned = new HashSet<string>();
foreach (var f in GetAllFieldsCore(new List<string>(), Fields))
{
var currentPrefix = prefix;
if (!string.IsNullOrEmpty(field.Name))
{

currentPrefix = $"{prefix}{field.Name}";
if (currentPrefix.Equals(name, StringComparison.InvariantCultureIgnoreCase))
{
return field;
}

currentPrefix += "_";
}

var subFieldResult = GetFieldByName(name, field.Fields, currentPrefix);
if (subFieldResult is not null)
{
return subFieldResult;
}

var fullName = String.Join('_', f.Path);
if (!nameReturned.Add(fullName))
continue;
yield return (fullName, f.Path, f.Field);
}
return null;
}

public List<string> GetAllNames()
public bool ValidateFieldNames(out List<string> errors)
{
return GetAllNames(Fields);
errors = new List<string>();
HashSet<string> nameReturned = new HashSet<string>();
foreach (var f in GetAllFieldsCore(new List<string>(), Fields))
{
var fullName = String.Join('_', f.Path);
if (!nameReturned.Add(fullName))
{
errors.Add($"Form contains duplicate field names '{fullName}'");
continue;
}
}
return errors.Count == 0;
}

private static List<string> GetAllNames(List<Field> fields)
IEnumerable<(List<string> Path, Field Field)> GetAllFieldsCore(List<string> path, List<Field> fields)
{
var names = new List<string>();

foreach (var field in fields)
{
string prefix = string.Empty;
List<string> thisPath = new List<string>(path.Count + 1);
thisPath.AddRange(path);
if (!string.IsNullOrEmpty(field.Name))
{
names.Add(field.Name);
prefix = $"{field.Name}_";
thisPath.Add(field.Name);
yield return (thisPath, field);
}

if (field.Fields.Any())
foreach (var child in field.Fields)
{
names.AddRange(GetAllNames(field.Fields).Select(s => $"{prefix}{s}"));
if (field.Constant)
child.Constant = true;
foreach (var descendant in GetAllFieldsCore(thisPath, field.Fields))
{
yield return descendant;
}
}
}

return names;
}

public void ApplyValuesFromOtherForm(Form form)
public void ApplyValuesFromForm(IEnumerable<KeyValuePair<string, StringValues>> form)
{
foreach (var fieldset in Fields)
var values = form.GroupBy(f => f.Key, f => f.Value).ToDictionary(g => g.Key, g => g.First());
foreach (var f in GetAllFields())
{
foreach (var field in fieldset.Fields)
{
field.Value = form
.GetFieldByName(
$"{(string.IsNullOrEmpty(fieldset.Name) ? string.Empty : fieldset.Name + "_")}{field.Name}")
?.Value;
}
if (f.Field.Constant || !values.TryGetValue(f.FullName, out var val))
continue;

f.Field.Value = val;
}
}

public void ApplyValuesFromForm(IFormCollection form)
public void SetValues(JObject values)
{
var fields = GetAllFields().ToDictionary(k => k.FullName, k => k.Field);
SetValues(fields, new List<string>(), values);
}

private void SetValues(Dictionary<string, Field> fields, List<string> path, JObject values)
{
var names = GetAllNames();
foreach (var name in names)
foreach (var prop in values.Properties())
{
var field = GetFieldByName(name);
if (field is null || !form.TryGetValue(name, out var val))
List<string> propPath = new List<string>(path.Count + 1);
propPath.AddRange(path);
propPath.Add(prop.Name);
if (prop.Value.Type == JTokenType.Object)
{
continue;
SetValues(fields, propPath, (JObject)prop.Value);
}
else if (prop.Value.Type == JTokenType.String)
{
var fullname = String.Join('_', propPath);
if (fields.TryGetValue(fullname, out var f) && !f.Constant)
f.Value = prop.Value.Value<string>();
}

field.Value = val;
}
}

public Dictionary<string, object> GetValues()
{
return GetValues(Fields);
}

private static Dictionary<string, object> GetValues(List<Field> fields)
public JObject GetValues()
{
var result = new Dictionary<string, object>();
foreach (Field field in fields)
var r = new JObject();
foreach (var f in GetAllFields())
{
var name = field.Name ?? string.Empty;
if (field.Fields.Any())
var node = r;
for (int i = 0; i < f.Path.Count - 1; i++)
{
var values = GetValues(fields);
values.Remove(string.Empty, out var keylessValue);

result.TryAdd(name, values);

if (keylessValue is not Dictionary<string, object> dict)
continue;
foreach (KeyValuePair<string, object> keyValuePair in dict)
var p = f.Path[i];
var child = node[p] as JObject;
if (child is null)
{
result.TryAdd(keyValuePair.Key, keyValuePair.Value);
child = new JObject();
node[p] = child;
}
node = child;
}
else
{
result.TryAdd(name, field.Value);
}
node[f.Field.Name] = f.Field.Value;
}

return result;
return r;
}
}
2 changes: 2 additions & 0 deletions BTCPayServer.Data/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, bool
public DbSet<WebhookData> Webhooks { get; set; }
public DbSet<LightningAddressData> LightningAddresses { get; set; }
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
public DbSet<FormData> Forms { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
Expand Down Expand Up @@ -128,6 +129,7 @@ protected override void OnModelCreating(ModelBuilder builder)
LightningAddressData.OnModelCreating(builder);
PayoutProcessorData.OnModelCreating(builder);
//WebhookData.OnModelCreating(builder);
FormData.OnModelCreating(builder, Database);


if (Database.IsSqlite() && !_designTime)
Expand Down
21 changes: 20 additions & 1 deletion BTCPayServer.Data/Data/FormData.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

namespace BTCPayServer.Data.Data;

public class FormData
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public string Id { get; set; }
public string Name { get; set; }
public string StoreId { get; set; }
public StoreData Store { get; set; }
public string Config { get; set; }
public bool Public { get; set; }

internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
builder.Entity<FormData>()
.HasOne(o => o.Store)
.WithMany(o => o.Forms).OnDelete(DeleteBehavior.Cascade);
builder.Entity<FormData>().HasIndex(o => o.StoreId);

if (databaseFacade.IsNpgsql())
{
builder.Entity<FormData>()
.Property(o => o.Config)
.HasColumnType("JSONB");
}
}
}
1 change: 1 addition & 0 deletions BTCPayServer.Data/Data/StoreData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public class StoreData
public IEnumerable<PayoutData> Payouts { get; set; }
public IEnumerable<CustodianAccountData> CustodianAccounts { get; set; }
public IEnumerable<StoreSettingData> Settings { get; set; }
public IEnumerable<FormData> Forms { get; set; }

internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
Expand Down
54 changes: 54 additions & 0 deletions BTCPayServer.Data/Migrations/20230125085242_AddForms.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;

#nullable disable

namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20230125085242_AddForms")]
public partial class AddForms : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
int? maxlength = migrationBuilder.IsMySql() ? 255 : null;
migrationBuilder.CreateTable(
name: "Forms",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false, maxLength: maxlength),
Name = table.Column<string>(type: "TEXT", nullable: true, maxLength: maxlength),
StoreId = table.Column<string>(type: "TEXT", nullable: true, maxLength: maxlength),
Config = table.Column<string>(type: migrationBuilder.IsNpgsql() ? "JSONB" : "TEXT", nullable: true),
Public = table.Column<bool>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Forms", x => x.Id);
table.ForeignKey(
name: "FK_Forms_Stores_StoreId",
column: x => x.StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});

migrationBuilder.CreateIndex(
name: "IX_Forms_StoreId",
table: "Forms",
column: "StoreId");
}

protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Forms");

}
}
}
Loading

0 comments on commit bbbaacc

Please sign in to comment.