mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +01:00
Generic Forms (#4561)
* 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 #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 <mail@dennisreimann.de> Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
parent
60f84d5e30
commit
bbbaacc350
30 changed files with 1174 additions and 354 deletions
|
@ -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,
|
||||
|
@ -26,14 +26,14 @@ public class Field
|
|||
// 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.
|
||||
|
@ -52,10 +52,10 @@ public class Field
|
|||
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()
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -20,6 +22,7 @@ public class Form
|
|||
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();
|
||||
|
||||
|
@ -32,126 +35,125 @@ public class Form
|
|||
return Fields.Select(f => f.IsValid()).All(o => o);
|
||||
}
|
||||
|
||||
public Field GetFieldByName(string name)
|
||||
public Field GetFieldByFullName(string fullName)
|
||||
{
|
||||
return GetFieldByName(name, Fields, null);
|
||||
}
|
||||
|
||||
private static Field GetFieldByName(string name, List<Field> fields, string prefix)
|
||||
{
|
||||
prefix ??= string.Empty;
|
||||
foreach (var field in fields)
|
||||
foreach (var f in GetAllFields())
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
if (f.FullName == fullName)
|
||||
return f.Field;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public List<string> GetAllNames()
|
||||
public IEnumerable<(string FullName, List<string> Path, Field Field)> GetAllFields()
|
||||
{
|
||||
return GetAllNames(Fields);
|
||||
}
|
||||
|
||||
private static List<string> GetAllNames(List<Field> fields)
|
||||
{
|
||||
var names = new List<string>();
|
||||
|
||||
foreach (var field in fields)
|
||||
HashSet<string> nameReturned = new HashSet<string>();
|
||||
foreach (var f in GetAllFieldsCore(new List<string>(), Fields))
|
||||
{
|
||||
string prefix = string.Empty;
|
||||
if (!string.IsNullOrEmpty(field.Name))
|
||||
{
|
||||
names.Add(field.Name);
|
||||
prefix = $"{field.Name}_";
|
||||
}
|
||||
|
||||
if (field.Fields.Any())
|
||||
{
|
||||
names.AddRange(GetAllNames(field.Fields).Select(s => $"{prefix}{s}"));
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
public void ApplyValuesFromOtherForm(Form form)
|
||||
{
|
||||
foreach (var fieldset in Fields)
|
||||
{
|
||||
foreach (var field in fieldset.Fields)
|
||||
{
|
||||
field.Value = form
|
||||
.GetFieldByName(
|
||||
$"{(string.IsNullOrEmpty(fieldset.Name) ? string.Empty : fieldset.Name + "_")}{field.Name}")
|
||||
?.Value;
|
||||
}
|
||||
var fullName = String.Join('_', f.Path);
|
||||
if (!nameReturned.Add(fullName))
|
||||
continue;
|
||||
yield return (fullName, f.Path, f.Field);
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyValuesFromForm(IFormCollection form)
|
||||
public bool ValidateFieldNames(out List<string> errors)
|
||||
{
|
||||
var names = GetAllNames();
|
||||
foreach (var name in names)
|
||||
errors = new List<string>();
|
||||
HashSet<string> nameReturned = new HashSet<string>();
|
||||
foreach (var f in GetAllFieldsCore(new List<string>(), Fields))
|
||||
{
|
||||
var field = GetFieldByName(name);
|
||||
if (field is null || !form.TryGetValue(name, out var val))
|
||||
var fullName = String.Join('_', f.Path);
|
||||
if (!nameReturned.Add(fullName))
|
||||
{
|
||||
errors.Add($"Form contains duplicate field names '{fullName}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
field.Value = val;
|
||||
}
|
||||
return errors.Count == 0;
|
||||
}
|
||||
|
||||
public Dictionary<string, object> GetValues()
|
||||
IEnumerable<(List<string> Path, Field Field)> GetAllFieldsCore(List<string> path, List<Field> fields)
|
||||
{
|
||||
return GetValues(Fields);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> GetValues(List<Field> fields)
|
||||
{
|
||||
var result = new Dictionary<string, object>();
|
||||
foreach (Field field in fields)
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var name = field.Name ?? string.Empty;
|
||||
if (field.Fields.Any())
|
||||
List<string> thisPath = new List<string>(path.Count + 1);
|
||||
thisPath.AddRange(path);
|
||||
if (!string.IsNullOrEmpty(field.Name))
|
||||
{
|
||||
var values = GetValues(fields);
|
||||
values.Remove(string.Empty, out var keylessValue);
|
||||
thisPath.Add(field.Name);
|
||||
yield return (thisPath, field);
|
||||
}
|
||||
|
||||
result.TryAdd(name, values);
|
||||
|
||||
if (keylessValue is not Dictionary<string, object> dict)
|
||||
continue;
|
||||
foreach (KeyValuePair<string, object> keyValuePair in dict)
|
||||
foreach (var child in field.Fields)
|
||||
{
|
||||
if (field.Constant)
|
||||
child.Constant = true;
|
||||
foreach (var descendant in GetAllFieldsCore(thisPath, field.Fields))
|
||||
{
|
||||
result.TryAdd(keyValuePair.Key, keyValuePair.Value);
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
else
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyValuesFromForm(IEnumerable<KeyValuePair<string, StringValues>> form)
|
||||
{
|
||||
var values = form.GroupBy(f => f.Key, f => f.Value).ToDictionary(g => g.Key, g => g.First());
|
||||
foreach (var f in GetAllFields())
|
||||
{
|
||||
if (f.Field.Constant || !values.TryGetValue(f.FullName, out var val))
|
||||
continue;
|
||||
|
||||
f.Field.Value = val;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
foreach (var prop in values.Properties())
|
||||
{
|
||||
List<string> propPath = new List<string>(path.Count + 1);
|
||||
propPath.AddRange(path);
|
||||
propPath.Add(prop.Name);
|
||||
if (prop.Value.Type == JTokenType.Object)
|
||||
{
|
||||
result.TryAdd(name, field.Value);
|
||||
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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
public JObject GetValues()
|
||||
{
|
||||
var r = new JObject();
|
||||
foreach (var f in GetAllFields())
|
||||
{
|
||||
var node = r;
|
||||
for (int i = 0; i < f.Path.Count - 1; i++)
|
||||
{
|
||||
var p = f.Path[i];
|
||||
var child = node[p] as JObject;
|
||||
if (child is null)
|
||||
{
|
||||
child = new JObject();
|
||||
node[p] = child;
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
node[f.Field.Name] = f.Field.Value;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -75,6 +75,7 @@ namespace BTCPayServer.Data
|
|||
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)
|
||||
{
|
||||
|
@ -128,6 +129,7 @@ namespace BTCPayServer.Data
|
|||
LightningAddressData.OnModelCreating(builder);
|
||||
PayoutProcessorData.OnModelCreating(builder);
|
||||
//WebhookData.OnModelCreating(builder);
|
||||
FormData.OnModelCreating(builder, Database);
|
||||
|
||||
|
||||
if (Database.IsSqlite() && !_designTime)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
|
@ -6,7 +6,26 @@ 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,6 +51,7 @@ namespace BTCPayServer.Data
|
|||
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)
|
||||
{
|
||||
|
|
54
BTCPayServer.Data/Migrations/20230125085242_AddForms.cs
Normal file
54
BTCPayServer.Data/Migrations/20230125085242_AddForms.cs
Normal file
|
@ -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");
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -205,6 +205,31 @@ namespace BTCPayServer.Migrations
|
|||
b.ToTable("CustodianAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.Data.FormData", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Config")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Public")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StoreId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StoreId");
|
||||
|
||||
b.ToTable("Forms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.Data.PayoutProcessorData", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
@ -705,8 +730,8 @@ namespace BTCPayServer.Migrations
|
|||
b.Property<int>("SpeedPolicy")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte[]>("StoreBlob")
|
||||
.HasColumnType("BLOB");
|
||||
b.Property<string>("StoreBlob")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("StoreCertificate")
|
||||
.HasColumnType("BLOB");
|
||||
|
@ -1129,6 +1154,16 @@ namespace BTCPayServer.Migrations
|
|||
b.Navigation("StoreData");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.Data.FormData", b =>
|
||||
{
|
||||
b.HasOne("BTCPayServer.Data.StoreData", "Store")
|
||||
.WithMany("Forms")
|
||||
.HasForeignKey("StoreId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Store");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.Data.PayoutProcessorData", b =>
|
||||
{
|
||||
b.HasOne("BTCPayServer.Data.StoreData", "Store")
|
||||
|
@ -1519,6 +1554,8 @@ namespace BTCPayServer.Migrations
|
|||
|
||||
b.Navigation("CustodianAccounts");
|
||||
|
||||
b.Navigation("Forms");
|
||||
|
||||
b.Navigation("Invoices");
|
||||
|
||||
b.Navigation("LightningAddresses");
|
||||
|
|
199
BTCPayServer.Tests/FormTes.cs
Normal file
199
BTCPayServer.Tests/FormTes.cs
Normal file
|
@ -0,0 +1,199 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using BTCPayServer.Abstractions.Form;
|
||||
using BTCPayServer.Forms;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace BTCPayServer.Tests;
|
||||
|
||||
[Trait("Fast", "Fast")]
|
||||
public class FormTests : UnitTestBase
|
||||
{
|
||||
public FormTests(ITestOutputHelper helper) : base(helper)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanParseForm()
|
||||
{
|
||||
var form = new Form()
|
||||
{
|
||||
Fields = new List<Field>
|
||||
{
|
||||
Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"),
|
||||
Field.Create("Name", "item2", 2.ToString(), true, null),
|
||||
Field.Create("Name", "invoice_test", 2.ToString(), true, null),
|
||||
new Field
|
||||
{
|
||||
Name = "invoice",
|
||||
Type = "fieldset",
|
||||
Fields = new List<Field>
|
||||
{
|
||||
Field.Create("Name", "test", 3.ToString(), true, null),
|
||||
Field.Create("Name", "item4", 4.ToString(), true, null),
|
||||
Field.Create("Name", "item5", 5.ToString(), true, null),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
var service = new FormDataService(null, null);
|
||||
Assert.False(service.IsFormSchemaValid(form.ToString(), out _, out _));
|
||||
form = new Form()
|
||||
{
|
||||
Fields = new List<Field>
|
||||
{
|
||||
Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"),
|
||||
Field.Create("Name", "item2", 2.ToString(), true, null),
|
||||
Field.Create("Name", "invoice_item3", 2.ToString(), true, null),
|
||||
new Field
|
||||
{
|
||||
Name = "invoice",
|
||||
Type = "fieldset",
|
||||
Fields = new List<Field> {Field.Create("Name", "test", 3.ToString(), true, null),}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Assert.True(service.IsFormSchemaValid(form.ToString(), out _, out _));
|
||||
form.ApplyValuesFromForm(new FormCollection(new Dictionary<string, StringValues>()
|
||||
{
|
||||
{"item1", new StringValues("updated")},
|
||||
{"item2", new StringValues("updated")},
|
||||
{"invoice_item3", new StringValues("updated")},
|
||||
{"invoice_test", new StringValues("updated")}
|
||||
}));
|
||||
foreach (var f in form.GetAllFields())
|
||||
{
|
||||
if (f.Field.Type == "fieldset")
|
||||
continue;
|
||||
Assert.Equal("updated", f.Field.Value);
|
||||
}
|
||||
|
||||
form = new Form()
|
||||
{
|
||||
Fields = new List<Field>
|
||||
{
|
||||
Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"),
|
||||
Field.Create("Name", "item2", 2.ToString(), true, null),
|
||||
Field.Create("Name", "invoice_item3", 2.ToString(), true, null),
|
||||
new Field
|
||||
{
|
||||
Name = "invoice",
|
||||
Type = "fieldset",
|
||||
Fields = new List<Field>
|
||||
{
|
||||
new() {Name = "test", Type = "text", Constant = true, Value = "original"}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
form.ApplyValuesFromForm(new FormCollection(new Dictionary<string, StringValues>()
|
||||
{
|
||||
{"item1", new StringValues("updated")},
|
||||
{"item2", new StringValues("updated")},
|
||||
{"invoice_item3", new StringValues("updated")},
|
||||
{"invoice_test", new StringValues("updated")}
|
||||
}));
|
||||
|
||||
foreach (var f in form.GetAllFields())
|
||||
{
|
||||
var field = f.Field;
|
||||
if (field.Type == "fieldset")
|
||||
continue;
|
||||
switch (f.FullName)
|
||||
{
|
||||
case "invoice_test":
|
||||
Assert.Equal("original", field.Value);
|
||||
break;
|
||||
default:
|
||||
Assert.Equal("updated", field.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
form = new Form()
|
||||
{
|
||||
Fields = new List<Field>
|
||||
{
|
||||
Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"),
|
||||
Field.Create("Name", "item2", 2.ToString(), true, null),
|
||||
Field.Create("Name", "invoice_item3", 2.ToString(), true, null),
|
||||
new Field
|
||||
{
|
||||
Name = "invoice",
|
||||
Type = "fieldset",
|
||||
Constant = true,
|
||||
Fields = new List<Field>
|
||||
{
|
||||
new() {Name = "test", Type = "text", Value = "original"}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
form.ApplyValuesFromForm(new FormCollection(new Dictionary<string, StringValues>()
|
||||
{
|
||||
{"item1", new StringValues("updated")},
|
||||
{"item2", new StringValues("updated")},
|
||||
{"invoice_item3", new StringValues("updated")},
|
||||
{"invoice_test", new StringValues("updated")}
|
||||
}));
|
||||
|
||||
foreach (var f in form.GetAllFields())
|
||||
{
|
||||
var field = f.Field;
|
||||
if (field.Type == "fieldset")
|
||||
continue;
|
||||
switch (f.FullName)
|
||||
{
|
||||
case "invoice_test":
|
||||
Assert.Equal("original", field.Value);
|
||||
break;
|
||||
default:
|
||||
Assert.Equal("updated", field.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var obj = form.GetValues();
|
||||
Assert.Equal("original", obj["invoice"]["test"].Value<string>());
|
||||
Assert.Equal("updated", obj["invoice_item3"].Value<string>());
|
||||
Clear(form);
|
||||
form.SetValues(obj);
|
||||
obj = form.GetValues();
|
||||
Assert.Equal("original", obj["invoice"]["test"].Value<string>());
|
||||
Assert.Equal("updated", obj["invoice_item3"].Value<string>());
|
||||
|
||||
form = new Form()
|
||||
{
|
||||
Fields = new List<Field>(){
|
||||
new Field
|
||||
{
|
||||
Type = "fieldset",
|
||||
Fields = new List<Field>
|
||||
{
|
||||
new() {Name = "test", Type = "text"}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
form.SetValues(obj);
|
||||
obj = form.GetValues();
|
||||
Assert.Null(obj["test"].Value<string>());
|
||||
form.SetValues(new JObject{ ["test"] = "hello" });
|
||||
obj = form.GetValues();
|
||||
Assert.Equal("hello", obj["test"].Value<string>());
|
||||
}
|
||||
|
||||
private void Clear(Form form)
|
||||
{
|
||||
foreach (var f in form.Fields.Where(f => !f.Constant))
|
||||
f.Value = null;
|
||||
}
|
||||
}
|
|
@ -86,8 +86,14 @@ namespace BTCPayServer.Tests
|
|||
Driver.AssertNoError();
|
||||
}
|
||||
|
||||
public void PayInvoice(bool mine = false)
|
||||
public void PayInvoice(bool mine = false, decimal? amount= null)
|
||||
{
|
||||
|
||||
if (amount is not null)
|
||||
{
|
||||
Driver.FindElement(By.Id("test-payment-amount")).Clear();
|
||||
Driver.FindElement(By.Id("test-payment-amount")).SendKeys(amount.ToString());
|
||||
}
|
||||
Driver.FindElement(By.Id("FakePayment")).Click();
|
||||
if (mine)
|
||||
{
|
||||
|
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
@ -117,6 +118,68 @@ namespace BTCPayServer.Tests
|
|||
s.Driver.FindElement(By.CssSelector("input[type='submit']")).Click();
|
||||
s.Driver.Navigate().GoToUrl(editUrl);
|
||||
Assert.Contains("aa@aa.com", s.Driver.PageSource);
|
||||
|
||||
//Custom Forms
|
||||
s.GoToStore(StoreNavPages.Forms);
|
||||
Assert.Contains("There are no forms yet.", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.Id("CreateForm")).Click();
|
||||
s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 1");
|
||||
s.Driver.FindElement((By.CssSelector("[data-form-template='email']"))).Click();
|
||||
var emailtemplate = s.Driver.FindElement(By.Name("FormConfig")).GetAttribute("value");
|
||||
Assert.Contains("buyerEmail", emailtemplate);
|
||||
s.Driver.FindElement(By.Name("FormConfig")).Clear();
|
||||
s.Driver.FindElement(By.Name("FormConfig"))
|
||||
.SendKeys(emailtemplate.Replace("Enter your email", "CustomFormInputTest"));
|
||||
s.Driver.FindElement(By.Id("SaveButton")).Click();
|
||||
s.Driver.FindElement(By.Id("ViewForm")).Click();
|
||||
|
||||
|
||||
var formurl = s.Driver.Url;
|
||||
Assert.Contains("CustomFormInputTest", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.Name("buyerEmail")).SendKeys("aa@aa.com");
|
||||
s.Driver.FindElement(By.CssSelector("input[type='submit']")).Click();
|
||||
s.PayInvoice(true);
|
||||
var result = await s.Server.PayTester.HttpClient.GetAsync(formurl);
|
||||
Assert.Equal(HttpStatusCode.NotFound, result.StatusCode);
|
||||
|
||||
s.GoToHome();
|
||||
s.GoToStore(StoreNavPages.Forms);
|
||||
Assert.Contains("Custom Form 1", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.LinkText("Remove")).Click();
|
||||
s.Driver.WaitForElement(By.Id("ConfirmInput")).SendKeys("DELETE");
|
||||
s.Driver.FindElement(By.Id("ConfirmContinue")).Click();
|
||||
|
||||
Assert.DoesNotContain("Custom Form 1", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.Id("CreateForm")).Click();
|
||||
s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 2");
|
||||
s.Driver.FindElement((By.CssSelector("[data-form-template='email']"))).Click();
|
||||
s.Driver.SetCheckbox(By.Name("Public"), true);
|
||||
|
||||
s.Driver.FindElement(By.Name("FormConfig")).Clear();
|
||||
s.Driver.FindElement(By.Name("FormConfig"))
|
||||
.SendKeys(emailtemplate.Replace("Enter your email", "CustomFormInputTest2"));
|
||||
s.Driver.FindElement(By.Id("SaveButton")).Click();
|
||||
s.Driver.FindElement(By.Id("ViewForm")).Click();
|
||||
formurl = s.Driver.Url;
|
||||
result = await s.Server.PayTester.HttpClient.GetAsync(formurl);
|
||||
Assert.NotEqual(HttpStatusCode.NotFound, result.StatusCode);
|
||||
|
||||
s.GoToHome();
|
||||
s.GoToStore(StoreNavPages.Forms);
|
||||
Assert.Contains("Custom Form 2", s.Driver.PageSource);
|
||||
|
||||
s.Driver.FindElement(By.LinkText("Custom Form 2")).Click();
|
||||
|
||||
s.Driver.FindElement(By.Name("Name")).Clear();
|
||||
s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 3");
|
||||
s.Driver.FindElement(By.Id("SaveButton")).Click();
|
||||
s.GoToStore(StoreNavPages.Forms);
|
||||
Assert.Contains("Custom Form 3", s.Driver.PageSource);
|
||||
|
||||
s.Driver.FindElement(By.Id("StoreNav-PaymentRequests")).Click();
|
||||
s.Driver.FindElement(By.Id("CreatePaymentRequest")).Click();
|
||||
Assert.Equal(4, new SelectElement(s.Driver.FindElement(By.Id("FormId"))).Options.Count);
|
||||
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
|
|
|
@ -370,7 +370,7 @@ namespace BTCPayServer.Controllers
|
|||
storedKeys.Add(item.Key);
|
||||
}
|
||||
|
||||
var formKeys = form.GetAllNames();
|
||||
var formKeys = form.GetAllFields().Select(f => f.FullName).ToHashSet();
|
||||
|
||||
foreach (var item in newData)
|
||||
{
|
||||
|
|
|
@ -10,6 +10,7 @@ using BTCPayServer.Client.Models;
|
|||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Forms;
|
||||
using BTCPayServer.Forms.Models;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.PaymentRequestViewModels;
|
||||
using BTCPayServer.PaymentRequest;
|
||||
|
@ -40,6 +41,7 @@ namespace BTCPayServer.Controllers
|
|||
private readonly StoreRepository _storeRepository;
|
||||
|
||||
private FormComponentProviders FormProviders { get; }
|
||||
public FormDataService FormDataService { get; }
|
||||
|
||||
public UIPaymentRequestController(
|
||||
UIInvoiceController invoiceController,
|
||||
|
@ -50,7 +52,8 @@ namespace BTCPayServer.Controllers
|
|||
CurrencyNameTable currencies,
|
||||
StoreRepository storeRepository,
|
||||
InvoiceRepository invoiceRepository,
|
||||
FormComponentProviders formProviders)
|
||||
FormComponentProviders formProviders,
|
||||
FormDataService formDataService)
|
||||
{
|
||||
_InvoiceController = invoiceController;
|
||||
_UserManager = userManager;
|
||||
|
@ -61,6 +64,7 @@ namespace BTCPayServer.Controllers
|
|||
_storeRepository = storeRepository;
|
||||
_InvoiceRepository = invoiceRepository;
|
||||
FormProviders = formProviders;
|
||||
FormDataService = formDataService;
|
||||
}
|
||||
|
||||
[BitpayAPIConstraint(false)]
|
||||
|
@ -204,7 +208,7 @@ namespace BTCPayServer.Controllers
|
|||
[HttpGet("{payReqId}/form")]
|
||||
[HttpPost("{payReqId}/form")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ViewPaymentRequestForm(string payReqId)
|
||||
public async Task<IActionResult> ViewPaymentRequestForm(string payReqId, FormViewModel viewModel)
|
||||
{
|
||||
var result = await _PaymentRequestRepository.FindPaymentRequest(payReqId, GetUserId());
|
||||
if (result == null)
|
||||
|
@ -213,42 +217,34 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
|
||||
var prBlob = result.GetBlob();
|
||||
var prFormId = prBlob.FormId;
|
||||
var formConfig = prFormId is null ? null : Forms.UIFormsController.GetFormData(prFormId)?.Config;
|
||||
switch (formConfig)
|
||||
if (prBlob.FormResponse is not null)
|
||||
{
|
||||
case null:
|
||||
case { } when !this.Request.HasFormContentType && prBlob.FormResponse is not null:
|
||||
return RedirectToAction("ViewPaymentRequest", new { payReqId });
|
||||
case { } when !this.Request.HasFormContentType && prBlob.FormResponse is null:
|
||||
break;
|
||||
default:
|
||||
// POST case: Handle form submit
|
||||
var formData = Form.Parse(formConfig);
|
||||
formData.ApplyValuesFromForm(Request.Form);
|
||||
if (FormProviders.Validate(formData, ModelState))
|
||||
{
|
||||
prBlob.FormResponse = JObject.FromObject(formData.GetValues());
|
||||
result.SetBlob(prBlob);
|
||||
await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result);
|
||||
return RedirectToAction("PayPaymentRequest", new { payReqId });
|
||||
}
|
||||
break;
|
||||
return RedirectToAction("PayPaymentRequest", new {payReqId});
|
||||
}
|
||||
var prFormId = prBlob.FormId;
|
||||
var formData = await FormDataService.GetForm(prFormId);
|
||||
if (formData is null)
|
||||
{
|
||||
|
||||
return RedirectToAction("PayPaymentRequest", new {payReqId});
|
||||
}
|
||||
|
||||
return View("PostRedirect", new PostRedirectViewModel
|
||||
var form = Form.Parse(formData.Config);
|
||||
if (Request.Method == "POST" && Request.HasFormContentType)
|
||||
{
|
||||
AspController = "UIForms",
|
||||
AspAction = "ViewPublicForm",
|
||||
RouteParameters =
|
||||
{
|
||||
{ "formId", prFormId }
|
||||
},
|
||||
FormParameters =
|
||||
{
|
||||
{ "redirectUrl", Request.GetCurrentUrl() }
|
||||
form.ApplyValuesFromForm(Request.Form);
|
||||
if (FormDataService.Validate(form, ModelState))
|
||||
{
|
||||
prBlob.FormResponse = form.GetValues();
|
||||
result.SetBlob(prBlob);
|
||||
await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result);
|
||||
return RedirectToAction("PayPaymentRequest", new {payReqId});
|
||||
}
|
||||
});
|
||||
}
|
||||
viewModel.FormName = formData.Name;
|
||||
viewModel.Form = form;
|
||||
return View("Views/UIForms/View", viewModel);
|
||||
|
||||
}
|
||||
|
||||
[HttpGet("{payReqId}/pay")]
|
||||
|
@ -277,6 +273,15 @@ namespace BTCPayServer.Controllers
|
|||
return BadRequest("Payment Request cannot be paid as it has been archived");
|
||||
}
|
||||
|
||||
if (!result.FormSubmitted && !string.IsNullOrEmpty(result.FormId))
|
||||
{
|
||||
var formData = await FormDataService.GetForm(result.FormId);
|
||||
if (formData is not null)
|
||||
{
|
||||
return RedirectToAction("ViewPaymentRequestForm", new {payReqId});
|
||||
}
|
||||
}
|
||||
|
||||
result.HubPath = PaymentRequestHub.GetHubPath(Request);
|
||||
if (result.AmountDue <= 0)
|
||||
{
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
using BTCPayServer.Abstractions.Form;
|
||||
using BTCPayServer.Data.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Newtonsoft.Json;
|
||||
|
@ -14,6 +15,11 @@ public static class FormDataExtensions
|
|||
serviceCollection.AddSingleton<IFormComponentProvider, HtmlInputFormProvider>();
|
||||
serviceCollection.AddSingleton<IFormComponentProvider, HtmlFieldsetFormProvider>();
|
||||
}
|
||||
|
||||
public static JObject Deserialize(this FormData form)
|
||||
{
|
||||
return JsonConvert.DeserializeObject<JObject>(form.Config);
|
||||
}
|
||||
|
||||
public static string Serialize(this JObject form)
|
||||
{
|
||||
|
|
|
@ -1,26 +1,48 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Form;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Data.Data;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Forms;
|
||||
|
||||
public class FormDataService
|
||||
{
|
||||
public const string InvoiceParameterPrefix = "invoice_";
|
||||
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
|
||||
private readonly FormComponentProviders _formProviders;
|
||||
|
||||
public FormDataService(
|
||||
ApplicationDbContextFactory applicationDbContextFactory,
|
||||
FormComponentProviders formProviders)
|
||||
{
|
||||
_applicationDbContextFactory = applicationDbContextFactory;
|
||||
_formProviders = formProviders;
|
||||
}
|
||||
|
||||
public static readonly Form StaticFormEmail = new()
|
||||
{
|
||||
Fields = new List<Field>() { Field.Create("Enter your email", "buyerEmail", null, true, null, "email") }
|
||||
Fields = new List<Field> { Field.Create("Enter your email", "buyerEmail", null, true, null, "email") }
|
||||
};
|
||||
|
||||
public static readonly Form StaticFormAddress = new()
|
||||
{
|
||||
Fields = new List<Field>()
|
||||
Fields = new List<Field>
|
||||
{
|
||||
Field.Create("Enter your email", "buyerEmail", null, true, null, "email"),
|
||||
Field.Create("Name", "buyerName", null, true, null),
|
||||
|
@ -32,4 +54,109 @@ public class FormDataService
|
|||
Field.Create("Country", "buyerCountry", null, true, null)
|
||||
}
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, (string selectText, string name, Form form)> _hardcodedOptions = new()
|
||||
{
|
||||
{"", ("Do not request any information", null, null)!},
|
||||
{"Email", ("Request email address only", "Provide your email address", StaticFormEmail )},
|
||||
{"Address", ("Request shipping address", "Provide your address", StaticFormAddress)},
|
||||
};
|
||||
|
||||
public async Task<SelectList> GetSelect(string storeId ,string selectedFormId)
|
||||
{
|
||||
var forms = await GetForms(storeId);
|
||||
return new SelectList(_hardcodedOptions.Select(pair => new SelectListItem(pair.Value.selectText, pair.Key, selectedFormId == pair.Key)).Concat(forms.Select(data => new SelectListItem(data.Name, data.Id, data.Id == selectedFormId))),
|
||||
nameof(SelectListItem.Value), nameof(SelectListItem.Text));
|
||||
}
|
||||
|
||||
public async Task<List<FormData>> GetForms(string storeId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(storeId);
|
||||
await using var context = _applicationDbContextFactory.CreateContext();
|
||||
return await context.Forms.Where(data => data.StoreId == storeId).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<FormData?> GetForm(string storeId, string? id)
|
||||
{
|
||||
if (id is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
await using var context = _applicationDbContextFactory.CreateContext();
|
||||
return await context.Forms.Where(data => data.Id == id && data.StoreId == storeId).FirstOrDefaultAsync();
|
||||
}
|
||||
public async Task<FormData?> GetForm(string? id)
|
||||
{
|
||||
if (id is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_hardcodedOptions.TryGetValue(id, out var hardcodedForm))
|
||||
{
|
||||
return new FormData
|
||||
{
|
||||
Config = hardcodedForm.form.ToString(),
|
||||
Id = id,
|
||||
Name = hardcodedForm.name,
|
||||
Public = false
|
||||
};
|
||||
}
|
||||
await using var context = _applicationDbContextFactory.CreateContext();
|
||||
return await context.Forms.Where(data => data.Id == id).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task RemoveForm(string id, string storeId)
|
||||
{
|
||||
await using var context = _applicationDbContextFactory.CreateContext();
|
||||
var item = await context.Forms.SingleOrDefaultAsync(data => data.StoreId == storeId && id == data.Id);
|
||||
if (item is not null)
|
||||
context.Remove(item);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task AddOrUpdateForm(FormData data)
|
||||
{
|
||||
await using var context = _applicationDbContextFactory.CreateContext();
|
||||
|
||||
context.Update(data);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public bool Validate(Form form, ModelStateDictionary modelState)
|
||||
{
|
||||
return _formProviders.Validate(form, modelState);
|
||||
}
|
||||
|
||||
public bool IsFormSchemaValid(string schema, [MaybeNullWhen(false)] out Form form, [MaybeNullWhen(false)] out string error)
|
||||
{
|
||||
error = null;
|
||||
form = null;
|
||||
try
|
||||
{
|
||||
form = Form.Parse(schema);
|
||||
if (!form.ValidateFieldNames(out var errors))
|
||||
{
|
||||
error = errors.First();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Form config was invalid: {ex.Message}";
|
||||
}
|
||||
return error is null && form is not null;
|
||||
}
|
||||
|
||||
public CreateInvoiceRequest GenerateInvoiceParametersFromForm(Form form)
|
||||
{
|
||||
var amt = form.GetFieldByFullName($"{InvoiceParameterPrefix}amount")?.Value;
|
||||
return new CreateInvoiceRequest
|
||||
{
|
||||
Currency = form.GetFieldByFullName($"{InvoiceParameterPrefix}currency")?.Value,
|
||||
Amount = string.IsNullOrEmpty(amt) ? null : decimal.Parse(amt, CultureInfo.InvariantCulture),
|
||||
|
||||
Metadata = form.GetValues(),
|
||||
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
using System.Collections.Generic;
|
||||
using BTCPayServer.Abstractions.Form;
|
||||
using BTCPayServer.Data.Data;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Forms.Models;
|
||||
|
||||
public class FormViewModel
|
||||
{
|
||||
public string LogoFileId { get; set; }
|
||||
public string CssFileId { get; set; }
|
||||
public string BrandColor { get; set; }
|
||||
public string StoreName { get; set; }
|
||||
public string FormName { get; set; }
|
||||
public string RedirectUrl { get; set; }
|
||||
public FormData FormData { get; set; }
|
||||
Form _Form;
|
||||
public Form Form { get => _Form ??= Form.Parse(FormData.Config); }
|
||||
public Form Form { get; set; }
|
||||
public string AspController { get; set; }
|
||||
public string AspAction { get; set; }
|
||||
public Dictionary<string, string> RouteParameters { get; set; } = new();
|
||||
}
|
||||
|
|
|
@ -8,4 +8,7 @@ public class ModifyForm
|
|||
|
||||
[DisplayName("Form configuration (JSON)")]
|
||||
public string FormConfig { get; set; }
|
||||
|
||||
[DisplayName("Allow form for public use")]
|
||||
public bool Public { get; set; }
|
||||
}
|
||||
|
|
|
@ -1,109 +1,203 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Form;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Data.Data;
|
||||
using BTCPayServer.Forms.Models;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Forms;
|
||||
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public class UIFormsController : Controller
|
||||
{
|
||||
private readonly FormDataService _formDataService;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private FormComponentProviders FormProviders { get; }
|
||||
|
||||
public UIFormsController(FormComponentProviders formProviders)
|
||||
public UIFormsController(FormComponentProviders formProviders, FormDataService formDataService,
|
||||
IAuthorizationService authorizationService)
|
||||
{
|
||||
FormProviders = formProviders;
|
||||
_formDataService = formDataService;
|
||||
_authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
[HttpGet("~/stores/{storeId}/forms")]
|
||||
public async Task<IActionResult> FormsList(string storeId)
|
||||
{
|
||||
var forms = await _formDataService.GetForms(storeId);
|
||||
|
||||
return View(forms);
|
||||
}
|
||||
|
||||
[HttpGet("~/stores/{storeId}/forms/new")]
|
||||
public IActionResult Create(string storeId)
|
||||
{
|
||||
var vm = new ModifyForm {FormConfig = new Form().ToString()};
|
||||
return View("Modify", vm);
|
||||
}
|
||||
|
||||
[HttpGet("~/stores/{storeId}/forms/modify/{id}")]
|
||||
public async Task<IActionResult> Modify(string storeId, string id)
|
||||
{
|
||||
var form = await _formDataService.GetForm(storeId, id);
|
||||
if (form is null) return NotFound();
|
||||
|
||||
var config = Form.Parse(form.Config);
|
||||
return View(new ModifyForm {Name = form.Name, FormConfig = config.ToString(), Public = form.Public});
|
||||
}
|
||||
|
||||
[HttpPost("~/stores/{storeId}/forms/modify/{id?}")]
|
||||
public async Task<IActionResult> Modify(string storeId, string? id, ModifyForm modifyForm)
|
||||
{
|
||||
if (id is not null)
|
||||
{
|
||||
if (await _formDataService.GetForm(storeId, id) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
if (!_formDataService.IsFormSchemaValid(modifyForm.FormConfig, out var form, out var error))
|
||||
{
|
||||
|
||||
ModelState.AddModelError(nameof(modifyForm.FormConfig),
|
||||
$"Form config was invalid: {error})");
|
||||
}
|
||||
else
|
||||
{
|
||||
modifyForm.FormConfig = form.ToString();
|
||||
}
|
||||
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(modifyForm);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var formData = new FormData
|
||||
{
|
||||
Id = id, StoreId = storeId, Name = modifyForm.Name, Config = modifyForm.FormConfig,Public = modifyForm.Public
|
||||
};
|
||||
var isNew = id is null;
|
||||
await _formDataService.AddOrUpdateForm(formData);
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Message = $"Form {(isNew ? "created" : "updated")} successfully."
|
||||
});
|
||||
if (isNew)
|
||||
{
|
||||
return RedirectToAction("Modify", new {storeId, id = formData.Id});
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ModelState.AddModelError("", $"An error occurred while saving: {e.Message}");
|
||||
}
|
||||
|
||||
return View(modifyForm);
|
||||
}
|
||||
|
||||
[HttpPost("~/stores/{storeId}/forms/{id}/remove")]
|
||||
public async Task<IActionResult> Remove(string storeId, string id)
|
||||
{
|
||||
await _formDataService.RemoveForm(id, storeId);
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success, Message = "Form removed"
|
||||
});
|
||||
return RedirectToAction("FormsList", new {storeId});
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("~/forms/{formId}")]
|
||||
[HttpPost("~/forms")]
|
||||
public IActionResult ViewPublicForm(string? formId, string? redirectUrl)
|
||||
public async Task<IActionResult> ViewPublicForm(string? formId)
|
||||
{
|
||||
if (!IsValidRedirectUri(redirectUrl))
|
||||
return BadRequest();
|
||||
|
||||
FormData? formData = string.IsNullOrEmpty(formId) ? null : GetFormData(formId);
|
||||
if (formData == null)
|
||||
FormData? formData = await _formDataService.GetForm(formId);
|
||||
if (formData?.Config is null)
|
||||
{
|
||||
return string.IsNullOrEmpty(redirectUrl)
|
||||
? NotFound()
|
||||
: Redirect(redirectUrl);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return GetFormView(formData, redirectUrl);
|
||||
if (!formData.Public &&
|
||||
!(await _authorizationService.AuthorizeAsync(User, Policies.CanViewStoreSettings)).Succeeded)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return GetFormView(formData);
|
||||
}
|
||||
|
||||
ViewResult GetFormView(FormData formData, string? redirectUrl)
|
||||
ViewResult GetFormView(FormData formData, Form? form = null)
|
||||
{
|
||||
return View("View", new FormViewModel { FormData = formData, RedirectUrl = redirectUrl });
|
||||
form ??= Form.Parse(formData.Config);
|
||||
form.ApplyValuesFromForm(Request.Query);
|
||||
var store = formData.Store;
|
||||
var storeBlob = store?.GetStoreBlob();
|
||||
|
||||
return View("View", new FormViewModel
|
||||
{
|
||||
FormName = formData.Name,
|
||||
Form = form,
|
||||
StoreName = store?.StoreName,
|
||||
BrandColor = storeBlob?.BrandColor,
|
||||
CssFileId = storeBlob?.CssFileId,
|
||||
LogoFileId = storeBlob?.LogoFileId,
|
||||
});
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("~/forms/{formId}")]
|
||||
public IActionResult SubmitForm(string formId, string? redirectUrl, string? command)
|
||||
public async Task<IActionResult> SubmitForm(string formId,
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
[FromServices] UIInvoiceController invoiceController)
|
||||
{
|
||||
if (!IsValidRedirectUri(redirectUrl))
|
||||
return BadRequest();
|
||||
|
||||
var formData = GetFormData(formId);
|
||||
var formData = await _formDataService.GetForm(formId);
|
||||
if (formData?.Config is null)
|
||||
return NotFound();
|
||||
|
||||
if (!Request.HasFormContentType)
|
||||
return GetFormView(formData, redirectUrl);
|
||||
|
||||
var conf = Form.Parse(formData.Config);
|
||||
conf.ApplyValuesFromForm(Request.Form);
|
||||
if (!FormProviders.Validate(conf, ModelState))
|
||||
return GetFormView(formData, redirectUrl);
|
||||
|
||||
var form = new MultiValueDictionary<string, string>();
|
||||
foreach (var kv in Request.Form)
|
||||
form.Add(kv.Key, kv.Value);
|
||||
|
||||
// With redirect, the form comes from another entity that we need to send the data back to
|
||||
if (!string.IsNullOrEmpty(redirectUrl))
|
||||
{
|
||||
return View("PostRedirect", new PostRedirectViewModel
|
||||
{
|
||||
FormUrl = redirectUrl,
|
||||
FormParameters = form
|
||||
});
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
internal static FormData? GetFormData(string id)
|
||||
{
|
||||
FormData? form = id switch
|
||||
if (!formData.Public &&
|
||||
!(await _authorizationService.AuthorizeAsync(User, Policies.CanViewStoreSettings)).Succeeded)
|
||||
{
|
||||
{ } formId when formId == GenericFormOption.Address.ToString() => new FormData
|
||||
{
|
||||
Config = FormDataService.StaticFormAddress.ToString(),
|
||||
Id = GenericFormOption.Address.ToString(),
|
||||
Name = "Provide your address",
|
||||
},
|
||||
{ } formId when formId == GenericFormOption.Email.ToString() => new FormData
|
||||
{
|
||||
Config = FormDataService.StaticFormEmail.ToString(),
|
||||
Id = GenericFormOption.Email.ToString(),
|
||||
Name = "Provide your email address",
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
return form;
|
||||
}
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
private bool IsValidRedirectUri(string? redirectUrl) =>
|
||||
!string.IsNullOrEmpty(redirectUrl) && Uri.TryCreate(redirectUrl, UriKind.RelativeOrAbsolute, out var uri) &&
|
||||
(Url.IsLocalUrl(redirectUrl) || uri.Host.Equals(Request.Host.Host));
|
||||
if (!Request.HasFormContentType)
|
||||
return GetFormView(formData);
|
||||
|
||||
var form = Form.Parse(formData.Config);
|
||||
form.ApplyValuesFromForm(Request.Form);
|
||||
|
||||
if (!_formDataService.Validate(form, ModelState))
|
||||
return GetFormView(formData, form);
|
||||
|
||||
// Create invoice after public form has been filled
|
||||
var store = await storeRepository.FindStore(formData.StoreId);
|
||||
if (store is null)
|
||||
return NotFound();
|
||||
|
||||
var request = _formDataService.GenerateInvoiceParametersFromForm(form);
|
||||
var inv = await invoiceController.CreateInvoiceCoreRaw(request, store, Request.GetAbsoluteRoot());
|
||||
|
||||
return RedirectToAction("Checkout", "UIInvoice", new {invoiceId = inv.Id});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ using BTCPayServer.Controllers;
|
|||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Forms;
|
||||
using BTCPayServer.Forms.Models;
|
||||
using BTCPayServer.ModelBinders;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Plugins.PointOfSale.Models;
|
||||
|
@ -42,21 +43,20 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||
CurrencyNameTable currencies,
|
||||
StoreRepository storeRepository,
|
||||
UIInvoiceController invoiceController,
|
||||
FormComponentProviders formProviders)
|
||||
FormDataService formDataService)
|
||||
{
|
||||
_currencies = currencies;
|
||||
_appService = appService;
|
||||
_storeRepository = storeRepository;
|
||||
_invoiceController = invoiceController;
|
||||
FormProviders = formProviders;
|
||||
FormDataService = formDataService;
|
||||
}
|
||||
|
||||
private readonly CurrencyNameTable _currencies;
|
||||
private readonly StoreRepository _storeRepository;
|
||||
private readonly AppService _appService;
|
||||
private readonly UIInvoiceController _invoiceController;
|
||||
|
||||
public FormComponentProviders FormProviders { get; }
|
||||
public FormDataService FormDataService { get; }
|
||||
|
||||
[HttpGet("/")]
|
||||
[HttpGet("/apps/{appId}/pos/{viewType?}")]
|
||||
|
@ -121,14 +121,15 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||
[DomainMappingConstraint(AppType.PointOfSale)]
|
||||
[RateLimitsFilter(ZoneLimits.PublicInvoices, Scope = RateLimitsScope.RemoteAddress)]
|
||||
public async Task<IActionResult> ViewPointOfSale(string appId,
|
||||
PosViewType? viewType,
|
||||
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal? amount,
|
||||
string email,
|
||||
string orderId,
|
||||
string notificationUrl,
|
||||
string redirectUrl,
|
||||
string choiceKey,
|
||||
PosViewType? viewType = null,
|
||||
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal? amount = null,
|
||||
string email = null,
|
||||
string orderId = null,
|
||||
string notificationUrl = null,
|
||||
string redirectUrl = null,
|
||||
string choiceKey = null,
|
||||
string posData = null,
|
||||
string formResponse = null,
|
||||
RequiresRefundEmail requiresRefundEmail = RequiresRefundEmail.InheritFromStore,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
@ -229,45 +230,38 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||
|
||||
var store = await _appService.GetStore(app);
|
||||
var posFormId = settings.FormId;
|
||||
|
||||
var formConfig = posFormId is null ? null : Forms.UIFormsController.GetFormData(posFormId)?.Config;
|
||||
JObject formResponse = null;
|
||||
switch (formConfig)
|
||||
var formData = await FormDataService.GetForm(posFormId);
|
||||
|
||||
JObject formResponseJObject = null;
|
||||
switch (formData)
|
||||
{
|
||||
case null:
|
||||
case { } when !this.Request.HasFormContentType:
|
||||
break;
|
||||
default:
|
||||
var formData = Form.Parse(formConfig);
|
||||
formData.ApplyValuesFromForm(this.Request.Form);
|
||||
|
||||
if (FormProviders.Validate(formData, ModelState))
|
||||
case not null:
|
||||
if (formResponse is null)
|
||||
{
|
||||
formResponse = JObject.FromObject(formData.GetValues());
|
||||
break;
|
||||
return View("PostRedirect", new PostRedirectViewModel
|
||||
{
|
||||
AspAction = nameof(POSForm),
|
||||
RouteParameters = new Dictionary<string, string> { { "appId", appId } },
|
||||
AspController = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture),
|
||||
FormParameters = new MultiValueDictionary<string, string>(Request.Form.Select(pair => new KeyValuePair<string, IReadOnlyCollection<string>>(pair.Key, pair.Value)))
|
||||
});
|
||||
}
|
||||
|
||||
var query = new QueryBuilder(Request.Query);
|
||||
foreach (var keyValuePair in Request.Form)
|
||||
formResponseJObject = JObject.Parse(formResponse);
|
||||
var form = Form.Parse(formData.Config);
|
||||
form.SetValues(formResponseJObject);
|
||||
if (!FormDataService.Validate(form, ModelState))
|
||||
{
|
||||
query.Add(keyValuePair.Key, keyValuePair.Value.ToArray());
|
||||
//someone tried to bypass validation
|
||||
return RedirectToAction(nameof(ViewPointOfSale), new {appId});
|
||||
}
|
||||
|
||||
// GET or empty form data case: Redirect to form
|
||||
return View("PostRedirect", new PostRedirectViewModel
|
||||
{
|
||||
AspController = "UIForms",
|
||||
AspAction = "ViewPublicForm",
|
||||
RouteParameters =
|
||||
{
|
||||
{ "formId", posFormId }
|
||||
},
|
||||
FormParameters =
|
||||
{
|
||||
{ "redirectUrl", Request.GetCurrentUrl() + query }
|
||||
}
|
||||
});
|
||||
formResponseJObject = form.GetValues();
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var invoice = await _invoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest
|
||||
|
@ -293,14 +287,14 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||
: requiresRefundEmail == RequiresRefundEmail.On,
|
||||
}, store, HttpContext.Request.GetAbsoluteRoot(),
|
||||
new List<string> { AppService.GetAppInternalTag(appId) },
|
||||
cancellationToken, (entity) =>
|
||||
cancellationToken, entity =>
|
||||
{
|
||||
entity.Metadata.OrderUrl = Request.GetDisplayUrl();
|
||||
|
||||
if (formResponse is not null)
|
||||
if (formResponseJObject is not null)
|
||||
{
|
||||
var meta = entity.Metadata.ToJObject();
|
||||
meta.Merge(formResponse);
|
||||
meta.Merge(formResponseJObject);
|
||||
entity.Metadata = InvoiceMetadata.FromJObject(meta);
|
||||
}
|
||||
});
|
||||
|
@ -314,10 +308,85 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||
Severity = StatusMessageModel.StatusSeverity.Error,
|
||||
AllowDismiss = true
|
||||
});
|
||||
return RedirectToAction(nameof(ViewPointOfSale), new { appId = appId });
|
||||
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("/apps/{appId}/pos/form")]
|
||||
public async Task<IActionResult> POSForm(string appId)
|
||||
{
|
||||
var app = await _appService.GetApp(appId, AppType.PointOfSale);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
var settings = app.GetSettings<PointOfSaleSettings>();
|
||||
var formData = await FormDataService.GetForm(settings.FormId);
|
||||
if (formData is null)
|
||||
{
|
||||
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
|
||||
}
|
||||
|
||||
var myDictionary = Request.Form
|
||||
.Where(pair => pair.Key != "__RequestVerificationToken")
|
||||
.ToDictionary(p => p.Key, p => p.Value.ToString());
|
||||
myDictionary.Add("appId", appId);
|
||||
var controller = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture);
|
||||
var redirectUrl = Url.Action(nameof(ViewPointOfSale), controller, myDictionary);
|
||||
var store = await _appService.GetStore(app);
|
||||
var storeBlob = store.GetStoreBlob();
|
||||
var form = Form.Parse(formData.Config);
|
||||
|
||||
return View("Views/UIForms/View", new FormViewModel
|
||||
{
|
||||
StoreName = store.StoreName,
|
||||
BrandColor = storeBlob.BrandColor,
|
||||
CssFileId = storeBlob.CssFileId,
|
||||
LogoFileId = storeBlob.LogoFileId,
|
||||
FormName = formData.Name,
|
||||
Form = form,
|
||||
RedirectUrl = redirectUrl,
|
||||
AspController = controller,
|
||||
AspAction = nameof(POSFormSubmit),
|
||||
RouteParameters = new Dictionary<string, string> { { "appId", appId } },
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/apps/{appId}/pos/form/submit")]
|
||||
public async Task<IActionResult> POSFormSubmit(string appId, FormViewModel viewModel)
|
||||
{
|
||||
var app = await _appService.GetApp(appId, AppType.PointOfSale);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
var settings = app.GetSettings<PointOfSaleSettings>();
|
||||
var formData = await FormDataService.GetForm(settings.FormId);
|
||||
if (formData is null || viewModel.RedirectUrl is null)
|
||||
{
|
||||
return RedirectToAction(nameof(ViewPointOfSale), new {appId });
|
||||
}
|
||||
|
||||
var form = Form.Parse(formData.Config);
|
||||
if (Request.Method == "POST" && Request.HasFormContentType)
|
||||
{
|
||||
form.ApplyValuesFromForm(Request.Form);
|
||||
if (FormDataService.Validate(form, ModelState))
|
||||
{
|
||||
return View("PostRedirect", new PostRedirectViewModel
|
||||
{
|
||||
FormUrl = viewModel.RedirectUrl,
|
||||
FormParameters =
|
||||
{
|
||||
{ "formResponse", form.GetValues().ToString() }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.FormName = formData.Name;
|
||||
viewModel.Form = form;
|
||||
|
||||
return View("Views/UIForms/View", viewModel);
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[HttpGet("{appId}/settings/pos")]
|
||||
public async Task<IActionResult> UpdatePointOfSale(string appId)
|
||||
|
@ -326,7 +395,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
var storeBlob = GetCurrentStore().GetStoreBlob();
|
||||
var settings = app.GetSettings<PointOfSaleSettings>();
|
||||
settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView;
|
||||
settings.EnableShoppingCart = false;
|
||||
|
@ -358,13 +426,13 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||
RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "",
|
||||
FormId = settings.FormId
|
||||
};
|
||||
if (HttpContext?.Request != null)
|
||||
if (HttpContext.Request != null)
|
||||
{
|
||||
var appUrl = HttpContext.Request.GetAbsoluteUri($"/apps/{appId}/pos");
|
||||
var encoder = HtmlEncoder.Default;
|
||||
if (settings.ShowCustomAmount)
|
||||
{
|
||||
StringBuilder builder = new StringBuilder();
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(CultureInfo.InvariantCulture, $"<form method=\"POST\" action=\"{encoder.Encode(appUrl)}\">");
|
||||
builder.AppendLine($" <input type=\"hidden\" name=\"amount\" value=\"100\" />");
|
||||
builder.AppendLine($" <input type=\"hidden\" name=\"email\" value=\"customer@example.com\" />");
|
||||
|
@ -443,7 +511,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||
Description = vm.Description,
|
||||
EmbeddedCSS = vm.EmbeddedCSS,
|
||||
RedirectAutomatically =
|
||||
string.IsNullOrEmpty(vm.RedirectAutomatically) ? (bool?)null : bool.Parse(vm.RedirectAutomatically)
|
||||
string.IsNullOrEmpty(vm.RedirectAutomatically) ? null : bool.Parse(vm.RedirectAutomatically)
|
||||
};
|
||||
|
||||
settings.FormId = vm.FormId;
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Logging;
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
|
||||
namespace BTCPayServer.Services.Stores;
|
||||
|
||||
public enum GenericFormOption
|
||||
{
|
||||
[Display(Name = "Do not request any information")]
|
||||
None,
|
||||
|
||||
[Display(Name = "Request email address only")]
|
||||
Email,
|
||||
|
||||
[Display(Name = "Request shipping address")]
|
||||
Address
|
||||
}
|
||||
|
||||
public static class CheckoutFormSelectList
|
||||
{
|
||||
public static SelectList WithSelected(string selectedFormId)
|
||||
{
|
||||
var choices = new List<SelectListItem>
|
||||
{
|
||||
GenericOptionItem(GenericFormOption.None),
|
||||
GenericOptionItem(GenericFormOption.Email),
|
||||
GenericOptionItem(GenericFormOption.Address)
|
||||
};
|
||||
|
||||
var chosen = choices.FirstOrDefault(t => t.Value == selectedFormId);
|
||||
return new SelectList(choices, nameof(SelectListItem.Value), nameof(SelectListItem.Text), chosen?.Value);
|
||||
}
|
||||
|
||||
private static string DisplayName(GenericFormOption opt) =>
|
||||
typeof(GenericFormOption).DisplayName(opt.ToString());
|
||||
|
||||
private static SelectListItem GenericOptionItem(GenericFormOption opt) =>
|
||||
new() { Text = DisplayName(opt), Value = opt == GenericFormOption.None ? null : opt.ToString() };
|
||||
}
|
|
@ -1,10 +1,8 @@
|
|||
@using BTCPayServer.Abstractions.Form
|
||||
@using BTCPayServer.Forms
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using Newtonsoft.Json.Linq
|
||||
@inject FormComponentProviders FormComponentProviders
|
||||
@model BTCPayServer.Abstractions.Form.Field
|
||||
@if (!Model.Hidden)
|
||||
@if (!Model.Constant)
|
||||
{
|
||||
<fieldset>
|
||||
<legend class="h3 mt-4 mb-3">@Model.Label</legend>
|
||||
|
|
|
@ -1,34 +1,26 @@
|
|||
@using BTCPayServer.Abstractions.Form
|
||||
@using Newtonsoft.Json.Linq
|
||||
@model BTCPayServer.Abstractions.Form.Field
|
||||
@{
|
||||
var isInvalid = this.ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid;
|
||||
var error = isInvalid ? this.ViewContext.ModelState[Model.Name].Errors[0].ErrorMessage : null;
|
||||
|
||||
var isInvalid = ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid;
|
||||
var errors = isInvalid ? ViewContext.ModelState[Model.Name].Errors : null;
|
||||
}
|
||||
<div class="form-group">
|
||||
@if (Model.Required)
|
||||
{
|
||||
<label class="form-label" for="@Model.Name" data-required>
|
||||
@Model.Label
|
||||
</label>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label class="form-label" for="@Model.Name">
|
||||
@Model.Label
|
||||
</label>
|
||||
}
|
||||
|
||||
<input class="form-control @(Model.IsValid() ? "" : "is-invalid")" id="@Model.Name" type="@Model.Type" required="@Model.Required" name="@Model.Name" value="@Model.Value" aria-describedby="@("HelpText" + Model.Name)"/>
|
||||
@if(isInvalid)
|
||||
{
|
||||
<span class="text-danger">@error</span>
|
||||
}
|
||||
<label class="form-label" for="@Model.Name"@(Model.Required ? " data-required" : "")>
|
||||
@Model.Label
|
||||
</label>
|
||||
<input id="@Model.Name" type="@Model.Type" class="form-control @(errors is null ? "" : "is-invalid")"
|
||||
name="@Model.Name" value="@Model.Value" data-val="true"
|
||||
@if (!string.IsNullOrEmpty(Model.HelpText))
|
||||
{
|
||||
@Safe.Raw($" aria-describedby=\"HelpText-{Model.Name}\"")
|
||||
}
|
||||
@if (Model.Required)
|
||||
{
|
||||
@Safe.Raw($" data-val-required=\"{Model.Label} is required.\" required")
|
||||
}
|
||||
/>
|
||||
<span class="text-danger" data-valmsg-for="@Model.Name" data-valmsg-replace="true">@(isInvalid && errors.Any() ? errors.First().ErrorMessage : string.Empty)</span>
|
||||
@if (!string.IsNullOrEmpty(Model.HelpText))
|
||||
{
|
||||
<div id="@("HelpText" + Model.Name)" class="form-text">@Model.HelpText</div>
|
||||
<div id="@($"HelpText-{Model.Name}")" class="form-text">@Model.HelpText</div>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
|
|
|
@ -2,12 +2,14 @@
|
|||
@using BTCPayServer.Abstractions.Models
|
||||
@using BTCPayServer.Views.Apps
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Forms
|
||||
@using BTCPayServer.Services.Stores
|
||||
@inject FormDataService FormDataService
|
||||
@model BTCPayServer.Plugins.PointOfSale.Models.UpdatePointOfSaleViewModel
|
||||
@{
|
||||
ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id);
|
||||
|
||||
var checkoutFormOptions = CheckoutFormSelectList.WithSelected(Model.FormId);
|
||||
|
||||
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
|
||||
}
|
||||
|
||||
<form method="post">
|
||||
|
|
|
@ -7,6 +7,6 @@
|
|||
{
|
||||
if (FormComponentProviders.TypeToComponentProvider.TryGetValue(field.Type, out var partial))
|
||||
{
|
||||
<partial name="@partial.View" for="@field"></partial>
|
||||
<partial name="@partial.View" for="@field" />
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,16 @@
|
|||
|
||||
<script src="~/vendor/jquery-validate/jquery.validate.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/jquery-validate-unobtrusive/jquery.validate.unobtrusive.js" asp-append-version="true"></script>
|
||||
|
||||
<script>
|
||||
$.validator.setDefaults({
|
||||
errorClass: '',
|
||||
validClass: '',
|
||||
highlight: function (element) {
|
||||
$(element).addClass('is-invalid').removeClass('is-valid');
|
||||
$(element.form).find(`[data-valmsg-for="${element.id}"]`).addClass('invalid-feedback');
|
||||
},
|
||||
unhighlight: function (element) {
|
||||
$(element).addClass("is-valid").removeClass("is-invalid");
|
||||
$(element.form).find(`[data-valmsg-for="${element.id}"]`).removeClass('invalid-feedback');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
55
BTCPayServer/Views/UIForms/FormsList.cshtml
Normal file
55
BTCPayServer/Views/UIForms/FormsList.cshtml
Normal file
|
@ -0,0 +1,55 @@
|
|||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Abstractions.Models
|
||||
@model List<BTCPayServer.Data.Data.FormData>
|
||||
@{
|
||||
Layout = "../Shared/_NavLayout.cshtml";
|
||||
ViewData["NavPartialName"] = "../UIStores/_Nav";
|
||||
ViewData.SetActivePage(StoreNavPages.Forms, "Forms");
|
||||
var storeId = Context.GetCurrentStoreId();
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xxl-constrain col-xl-10">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="mb-0">@ViewData["Title"]</h3>
|
||||
<a asp-action="Create" asp-route-storeId="@storeId" class="btn btn-primary mt-3 mt-sm-0" role="button" id="CreateForm">
|
||||
<span class="fa fa-plus"></span>
|
||||
Create Form
|
||||
</a>
|
||||
</div>
|
||||
@if (Model.Any())
|
||||
{
|
||||
<table class="table table-hover table-responsive-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-action="Modify" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Edit-@item.Name">@item.Name</a>
|
||||
</td>
|
||||
|
||||
<td class="text-end">
|
||||
<a asp-action="Remove" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Remove-@item.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DELETE">Remove</a> -
|
||||
<a asp-action="ViewPublicForm" asp-route-formId="@item.Id" id="View-@item.Name">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-secondary mt-3">
|
||||
There are no forms yet.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<partial name="_Confirm" model="@(new ConfirmModel("Delete form", "This form will be removed from this store.", "Delete"))" />
|
87
BTCPayServer/Views/UIForms/Modify.cshtml
Normal file
87
BTCPayServer/Views/UIForms/Modify.cshtml
Normal file
|
@ -0,0 +1,87 @@
|
|||
@using BTCPayServer.Forms
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
@using Newtonsoft.Json
|
||||
@model BTCPayServer.Forms.ModifyForm
|
||||
|
||||
@{
|
||||
var formId = Context.GetRouteValue("id");
|
||||
var isNew = formId is null;
|
||||
Layout = "../Shared/_NavLayout.cshtml";
|
||||
ViewData["NavPartialName"] = "../UIStores/_Nav";
|
||||
ViewData.SetActivePage(StoreNavPages.Forms, $"{(isNew ? "Create" : "Edit")} Form", Model.Name);
|
||||
|
||||
var storeId = Context.GetCurrentStoreId();
|
||||
}
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial"/>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const $config = document.getElementById("FormConfig");
|
||||
delegate("click", "[data-form-template]", e => {
|
||||
const { formTemplate: id } = e.target.dataset
|
||||
const $template = document.getElementById(`form-template-${id}`)
|
||||
$config.value = $template.innerHTML.trim()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
}
|
||||
|
||||
<template id="form-template-email">
|
||||
@Json.Serialize(FormDataService.StaticFormEmail, new JsonSerializerSettings()
|
||||
{
|
||||
Formatting = Formatting.Indented
|
||||
})
|
||||
</template>
|
||||
<template id="form-template-address">
|
||||
@Json.Serialize(FormDataService.StaticFormAddress, new JsonSerializerSettings()
|
||||
{
|
||||
Formatting = Formatting.Indented
|
||||
})
|
||||
</template>
|
||||
|
||||
<form method="post" asp-action="Modify" asp-route-id="@formId" asp-route-storeId="@storeId">
|
||||
<div class="row">
|
||||
<div class="col-xl-10 col-xxl-constrain">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="mb-0">@ViewData["Title"]</h3>
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
<button type="submit" class="btn btn-primary order-sm-1" id="SaveButton">Save</button>
|
||||
@if (!isNew)
|
||||
{
|
||||
<a class="btn btn-secondary" asp-action="ViewPublicForm" asp-route-formId="@formId" id="ViewForm">View</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Name" class="form-label" data-required></label>
|
||||
<input asp-for="Name" class="form-control" required/>
|
||||
<span asp-validation-for="Name" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mb-4 gap-3">
|
||||
<input asp-for="Public" type="checkbox" class="btcpay-toggle" />
|
||||
<div>
|
||||
<label asp-for="Public"></label>
|
||||
<div class="form-text" style="max-width:27rem">
|
||||
Standalone mode, which can be used to generate invoices
|
||||
independent of payment requests or apps.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
||||
<label asp-for="FormConfig" class="form-label" data-required></label>
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<span>Templates:</span>
|
||||
<button type="button" class="btn btn-link p-0" data-form-template="email">Email</button>
|
||||
<button type="button" class="btn btn-link p-0" data-form-template="address">Address</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea asp-for="FormConfig" class="form-control" rows="10" cols="21"></textarea>
|
||||
<span asp-validation-for="FormConfig" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
|
@ -1,39 +1,58 @@
|
|||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@inject BTCPayServer.Services.BTCPayServerEnvironment env
|
||||
@inject BTCPayServer.Services.BTCPayServerEnvironment Env
|
||||
@model BTCPayServer.Forms.Models.FormViewModel
|
||||
@{
|
||||
Layout = null;
|
||||
ViewData["Title"] = Model.FormData.Name;
|
||||
ViewData["Title"] = Model.FormName;
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" @(env.IsDeveloping ? " data-devenv" : "")>
|
||||
<html lang="en" @(Env.IsDeveloping ? " data-devenv" : "")>
|
||||
<head>
|
||||
<partial name="LayoutHead"/>
|
||||
<partial name="LayoutHead" />
|
||||
<partial name="LayoutHeadStoreBranding" model="@(Model.BrandColor, Model.CssFileId, "", "")" />
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
</head>
|
||||
<body class="min-vh-100">
|
||||
<div class="public-page-wrap flex-column">
|
||||
<main class="flex-grow-1">
|
||||
<div class="container" style="max-width:720px;">
|
||||
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) {{"Margin", "mb-4"}})"/>
|
||||
@if (!ViewContext.ModelState.IsValid)
|
||||
{
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
}
|
||||
<partial name="_FormTopMessages" model="@Model.Form"/>
|
||||
<div class="d-flex flex-column justify-content-center gap-4">
|
||||
<h1 class="h3 text-center">@ViewData["Title"]</h1>
|
||||
<div class="bg-tile p-3 p-sm-4 rounded">
|
||||
<form asp-action="SubmitForm" asp-route-formId="@Model.FormData.Id">
|
||||
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })" />
|
||||
@if (!string.IsNullOrEmpty(Model.StoreName) || !string.IsNullOrEmpty(Model.LogoFileId))
|
||||
{
|
||||
<partial name="_StoreHeader" model="(Model.StoreName, Model.LogoFileId)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1 class="h3 text-center mt-3">@ViewData["Title"]</h1>
|
||||
}
|
||||
<main class="flex-grow-1 container" style="max-width:576px">
|
||||
@if (!ViewContext.ModelState.IsValid)
|
||||
{
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
}
|
||||
<partial name="_FormTopMessages" model="@Model.Form" />
|
||||
<div class="d-flex flex-column justify-content-center gap-4">
|
||||
<div class="bg-tile p-3 p-sm-4 rounded">
|
||||
@if (string.IsNullOrEmpty(Model.AspAction))
|
||||
{
|
||||
<form method="post" novalidate="novalidate">
|
||||
@if (!string.IsNullOrEmpty(Model.RedirectUrl))
|
||||
{
|
||||
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/>
|
||||
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl" />
|
||||
}
|
||||
<partial name="_Form" model="@Model.Form"/>
|
||||
<input type="submit" class="btn btn-primary" name="command" value="Submit"/>
|
||||
<partial name="_Form" model="@Model.Form" />
|
||||
<input type="submit" class="btn btn-primary" name="command" value="Submit" />
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form method="post" asp-action="@Model.AspAction" asp-controller="@Model.AspController" asp-all-route-data="Model.RouteParameters">
|
||||
@if (!string.IsNullOrEmpty(Model.RedirectUrl))
|
||||
{
|
||||
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl" />
|
||||
}
|
||||
<partial name="_Form" model="@Model.Form" />
|
||||
<input type="submit" class="btn btn-primary" name="command" value="Submit" />
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
@ -43,6 +62,7 @@
|
|||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<partial name="LayoutFoot"/>
|
||||
<partial name="LayoutFoot" />
|
||||
<partial name="_ValidationScriptsPartial"/>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
@using BTCPayServer.Services.PaymentRequests
|
||||
@using System.Globalization
|
||||
@using BTCPayServer.Forms
|
||||
@using BTCPayServer.Services.Stores
|
||||
@using BTCPayServer.TagHelpers
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@inject FormDataService FormDataService
|
||||
@model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel
|
||||
@{
|
||||
var checkoutFormOptions = CheckoutFormSelectList.WithSelected(Model.FormId);
|
||||
|
||||
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
|
||||
|
||||
ViewData.SetActivePage(PaymentRequestsNavPages.Create, $"{(string.IsNullOrEmpty(Model.Id) ? "Create" : "Edit")} Payment Request", Model.Id);
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Webhooks)" asp-controller="UIStores" asp-action="Webhooks" asp-route-storeId="@storeId">Webhooks</a>
|
||||
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.PayoutProcessors))" class="nav-link @ViewData.IsActivePage(StoreNavPages.PayoutProcessors)" asp-controller="UIPayoutProcessors" asp-action="ConfigureStorePayoutProcessors" asp-route-storeId="@storeId">Payout Processors</a>
|
||||
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Emails))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Emails)" asp-controller="UIStores" asp-action="StoreEmailSettings" asp-route-storeId="@storeId">Emails</a>
|
||||
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Forms))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Forms)" asp-controller="UIForms" asp-action="FormsList" asp-route-storeId="@storeId">Forms</a>
|
||||
<vc:ui-extension-point location="store-nav" model="@Model"/>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
Loading…
Add table
Reference in a new issue