Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Each skill teaches domain-specific workflows for Fusion (Rust core + Python / No
| `fusion-release` | Version bumps, manifests, publish prep |
| `fusion-testing` | Running checks; investigating failed tests |
| `fusion-cache` | Application cache (moka default; Redis later) |
| `fusion-background-tasks` | Tokio spawn / cancel / status / snapshot |
| `fusion-template-forms` | Template `form` / `ok` / `fail` + SPA `data-fusion-form` |

## Always-on rules

Expand Down
1 change: 1 addition & 0 deletions .agents/skills/fusion-bindings-parity/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ New public surface → show usage in **Python + Node + C#**. Prefer the same bas
| OpenAPI / Swagger | `app.py` + `api_types.rs` | `buildOpenApi` in `index.js` | `Swagger.cs` |
| Version navbar | per-version OpenAPI routes | same | same |
| Template routes | omit from OpenAPI | omit | omit |
| Template forms | `form` / `ok` / `fail` + `data-fusion-form` | same | `Form` / `Ok` / `Fail` |

## Verification commands

Expand Down
1 change: 1 addition & 0 deletions .agents/skills/fusion-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ C# (`asp-core`): `main.cs`, `*.csproj` (`net10.0`), `[Route]` / `[HttpGet]`.
### What the starter demonstrates

- `FusionBaseTemplate` at `/` (Tera templates; **not** listed in Swagger).
- HTML forms: `form` / `ok` / `fail` + optional `data-fusion-form` (see skill `fusion-template-forms`).
- Welcome UI via built-in components: `fusion.badge`, `fusion.button`, `fusion.card`, `fusion.table` (optional `page_size={10}` for client-side row pagination; styles: `{% include "fusion/components.css" %}`).
- `FusionBaseApi` at `api/[module]` with `version="v1"` → `/v1/api/product/…`.
- Convention verbs (`get` / `post` / …) plus one custom slot (`http_get` / `httpGet` / `[HttpGet]` with `[action]`).
Expand Down
54 changes: 54 additions & 0 deletions .agents/skills/fusion-template-forms/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
name: fusion-template-forms
description: >-
Documents FusionBaseTemplate form helpers (form, ok, fail), context vs get vs
post, and SPA-friendly data-fusion-form + fusion/form.js. Use when building
HTML forms that POST to the same page.
---

# Template forms (SPA-friendly)

## Mental model

| Method | Role |
|--------|------|
| `context()` | Template **data** (title, fields). Not an HTTP verb. |
| `get()` | **HTTP GET** — renders `context()` as HTML (or JSON if `Accept: application/json`). Rarely override. |
| `post()` | **HTTP POST** — read `form`, validate with normal `if`, return `ok` / `fail`. |

## API

| Python | Node | C# |
|--------|------|-----|
| `self.form` | `this.form` | `Form` |
| `self.fail(errors, message=..., **fields)` | `this.fail(errors, { message, ...fields })` | `Fail(errors, message, fields)` |
| `self.ok(message=..., **fields)` | `this.ok({ message, ...fields })` | `Ok(message, fields)` |

- JSON clients (`Accept: application/json` or SPA fetch) get `{ ok, message, errors, fields }`.
- HTML clients re-render the **same** template with errors/fields (no separate “success page” required).

## SPA markup

```html
<form method="post" action="/register" data-fusion-form>
<div id="fusion-form-status"></div>
<input name="phone" />
<span class="fusion-field-error" data-field="phone"></span>
<button type="submit">Save</button>
</form>
<script>{% include "fusion/form.js" %}</script>
```

Built-in script lives at `fusion/form.js` (embedded in fusion-core). Without JS, classic POST still works via `ok`/`fail` HTML path.

## Example

```python
def post(self):
form = self.form
if not form.get("phone"):
return self.fail({"phone": "required"}, **form)
return self.ok(message="Saved.", **form)
```

See `examples/template_form.{py,mjs,cs}`.
157 changes: 156 additions & 1 deletion bindings/csharp/FusionFramework/FusionBaseTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,71 @@ public abstract class FusionBaseTemplate : FusionBaseApi
public static string TemplateAddress { get; set; } = "";
public static string TemplatesDir { get; set; } = "";

/// <summary>Sync template variables (override in subclasses).</summary>
/// <summary>
/// Template variables (not an HTTP verb). Override in subclasses.
/// <see cref="Get"/> renders this as HTML; POST handlers use <see cref="Form"/> / <see cref="Ok"/> / <see cref="Fail"/>.
/// </summary>
public virtual Dictionary<string, JsonNode?> Context() => new();

/// <summary>Async template variables; default wraps <see cref="Context"/>.</summary>
public virtual Task<Dictionary<string, JsonNode?>> ContextAsync() =>
Task.FromResult(Context());

/// <summary>Parsed POST body (urlencoded or JSON) as flat string fields.</summary>
public Dictionary<string, string> Form => ParseFormBody(Body, ContentType());

/// <summary>Parse urlencoded or JSON body into flat string fields.</summary>
public static Dictionary<string, string> ParseFormBody(string? body, string? contentType)
{
var raw = body ?? "";
var ct = (contentType ?? "").ToLowerInvariant();
var outDict = new Dictionary<string, string>(StringComparer.Ordinal);
if (ct.Contains("application/json", StringComparison.Ordinal)
|| (raw.TrimStart().StartsWith('{') && !ct.Contains("urlencoded", StringComparison.Ordinal)))
{
try
{
var node = string.IsNullOrWhiteSpace(raw) ? null : JsonNode.Parse(raw);
if (node is JsonObject obj)
{
foreach (var kv in obj)
outDict[kv.Key] = kv.Value is null || kv.Value.GetValueKind() == JsonValueKind.Null
? ""
: kv.Value.ToString() ?? "";
}
}
catch (JsonException)
{
// ignore invalid JSON
}
return outDict;
}

if (string.IsNullOrEmpty(raw))
return outDict;

foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var parts = pair.Split('=', 2);
var key = Uri.UnescapeDataString(parts[0].Replace('+', ' '));
var value = parts.Length > 1
? Uri.UnescapeDataString(parts[1].Replace('+', ' '))
: "";
outDict[key] = value;
}
return outDict;
}

string? ContentType()
{
foreach (var kv in Request.Headers)
{
if (string.Equals(kv.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
return kv.Value;
}
return null;
}

/// <summary>
/// Default GET — HTML or JSON context. Uses <see cref="ContextAsync"/> so
/// subclasses can override that for DB/API-backed pages (Python async context parity).
Expand All @@ -42,6 +100,103 @@ object FinishGet(Dictionary<string, JsonNode?> ctx)
return HtmlResponse(ctx);
}

/// <summary>Validation failure — JSON for SPA fetch, else same template with errors.</summary>
public object Fail(
IDictionary<string, string>? errors = null,
string? message = null,
IDictionary<string, string>? fields = null)
{
var err = errors?.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal)
?? new Dictionary<string, string>(StringComparer.Ordinal);
var flat = fields?.ToDictionary(
kv => kv.Key,
kv => kv.Value ?? "",
StringComparer.Ordinal)
?? new Dictionary<string, string>(StringComparer.Ordinal);
var msg = message ?? "Validation failed";
if (WantsJson())
{
return Response(new Dictionary<string, object?>
{
["ok"] = false,
["message"] = msg,
["errors"] = err,
["fields"] = flat,
}, 400);
}
return FormHtmlResult(ok: false, message: msg, errors: err, fields: flat, status: 400);
}

/// <summary>Success — JSON for SPA fetch, else same template with ok=true.</summary>
public object Ok(string? message = null, IDictionary<string, string>? fields = null)
{
var flat = fields?.ToDictionary(
kv => kv.Key,
kv => kv.Value ?? "",
StringComparer.Ordinal)
?? new Dictionary<string, string>(StringComparer.Ordinal);
var msg = message ?? "OK";
if (WantsJson())
{
return Response(new Dictionary<string, object?>
{
["ok"] = true,
["message"] = msg,
["errors"] = new Dictionary<string, string>(),
["fields"] = flat,
}, 200);
}
return FormHtmlResult(
ok: true,
message: msg,
errors: new Dictionary<string, string>(),
fields: flat,
status: 200);
}

object FormHtmlResult(
bool ok,
string message,
IDictionary<string, string> errors,
IDictionary<string, string> fields,
int status)
{
var task = ContextAsync();
if (!task.IsCompletedSuccessfully)
return FormHtmlResultAsync(task, ok, message, errors, fields, status);
return FinishFormHtml(task.Result, ok, message, errors, fields, status);
}

async Task<object> FormHtmlResultAsync(
Task<Dictionary<string, JsonNode?>> task,
bool ok,
string message,
IDictionary<string, string> errors,
IDictionary<string, string> fields,
int status)
{
var ctx = await task.ConfigureAwait(false);
return FinishFormHtml(ctx, ok, message, errors, fields, status);
}

object FinishFormHtml(
Dictionary<string, JsonNode?> ctx,
bool ok,
string message,
IDictionary<string, string> errors,
IDictionary<string, string> fields,
int status)
{
var data = new Dictionary<string, JsonNode?>(ctx, StringComparer.Ordinal);
foreach (var kv in fields)
data[kv.Key] = JsonValue.Create(kv.Value);
data["ok"] = JsonValue.Create(ok);
data["message"] = JsonValue.Create(message);
data["errors"] = JsonSerializer.SerializeToNode(errors);
data["fields"] = JsonSerializer.SerializeToNode(fields);
return HtmlResponse(data, status);
}

public virtual string TemplateName()
{
var name = !string.IsNullOrEmpty(Template) ? Template : TemplateAddress;
Expand Down
99 changes: 99 additions & 0 deletions crates/fusion-core/assets/templates/fusion/form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Progressive-enhancement forms for Fusion templates.
*
* Usage:
* <form data-fusion-form method="post" action="/register">
* <div id="fusion-form-status"></div>
* <input name="phone" />
* <span class="fusion-field-error" data-field="phone"></span>
* </form>
* <script> /* include builtin fusion/form.js here */ </script>
*
* Posts with Accept: application/json and paints ok/errors without leaving the page.
* Without JS, the browser does a normal HTML POST (server still uses ok/fail).
*/

(function () {
function clearErrors(form) {
form.querySelectorAll(".fusion-field-error").forEach(function (el) {
el.textContent = "";
});
var status = form.querySelector("#fusion-form-status") || document.getElementById("fusion-form-status");
if (status) {
status.textContent = "";
status.classList.remove("fusion-form-ok", "fusion-form-fail");
}
}

function paintErrors(form, errors) {
if (!errors || typeof errors !== "object") return;
Object.keys(errors).forEach(function (key) {
var el =
form.querySelector('.fusion-field-error[data-field="' + key + '"]') ||
document.querySelector('.fusion-field-error[data-field="' + key + '"]');
if (el) el.textContent = String(errors[key] || "");
});
}

function paintStatus(form, message, ok) {
var status = form.querySelector("#fusion-form-status") || document.getElementById("fusion-form-status");
if (!status) return;
status.textContent = message || (ok ? "OK" : "Error");
status.classList.toggle("fusion-form-ok", !!ok);
status.classList.toggle("fusion-form-fail", !ok);
}

function fillFields(form, fields) {
if (!fields || typeof fields !== "object") return;
Object.keys(fields).forEach(function (key) {
var input = form.querySelector('[name="' + key + '"]');
if (!input || input.type === "password") return;
input.value = fields[key] == null ? "" : String(fields[key]);
});
}

document.addEventListener(
"submit",
function (event) {
var form = event.target;
if (!form || !form.getAttribute || !form.hasAttribute("data-fusion-form")) return;
event.preventDefault();
clearErrors(form);

var action = form.getAttribute("action") || window.location.pathname;
var method = (form.getAttribute("method") || "post").toUpperCase();
var body = new URLSearchParams(new FormData(form));

fetch(action, {
method: method,
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
body: body.toString(),
credentials: "same-origin",
})
.then(function (res) {
return res.json().then(function (data) {
return { res: res, data: data };
});
})
.then(function (payload) {
var data = payload.data || {};
fillFields(form, data.fields);
if (data.ok) {
paintStatus(form, data.message || "Saved", true);
form.dispatchEvent(new CustomEvent("fusion:form-ok", { detail: data }));
return;
}
paintErrors(form, data.errors);
paintStatus(form, data.message || "Validation failed", false);
form.dispatchEvent(new CustomEvent("fusion:form-fail", { detail: data }));
})
.catch(function () {
paintStatus(form, "Request failed", false);
});
},
true
);
})();
5 changes: 5 additions & 0 deletions crates/fusion-core/src/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const BUILTIN_BASE: &str = include_str!("../assets/templates/fusion/base.html");
const BUILTIN_COMPONENTS_CSS: &str =
include_str!("../assets/templates/fusion/components.css");
const BUILTIN_MONITOR: &str = include_str!("../assets/templates/fusion/monitor.html");
const BUILTIN_FORM_JS: &str = include_str!("../assets/templates/fusion/form.js");

static ENGINE_CACHE: Mutex<Option<EngineCache>> = Mutex::new(None);

Expand Down Expand Up @@ -74,6 +75,10 @@ fn build_engine(root: &Path) -> Result<Tera, String> {
"fusion/cache_monitor.html".to_string(),
BUILTIN_MONITOR.to_string(),
),
(
"fusion/form.js".to_string(),
BUILTIN_FORM_JS.to_string(),
),
];

if root.is_dir() {
Expand Down
Loading
Loading