Skip to content

Commit 69febd4

Browse files
authored
Merge pull request #24 from cipherunits/feature/spa
feat(template): add spa style to template
2 parents be5ee19 + 62dee33 commit 69febd4

14 files changed

Lines changed: 875 additions & 20 deletions

File tree

‎.agents/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Each skill teaches domain-specific workflows for Fusion (Rust core + Python / No
2323
| `fusion-release` | Version bumps, manifests, publish prep |
2424
| `fusion-testing` | Running checks; investigating failed tests |
2525
| `fusion-cache` | Application cache (moka default; Redis later) |
26+
| `fusion-background-tasks` | Tokio spawn / cancel / status / snapshot |
27+
| `fusion-template-forms` | Template `form` / `ok` / `fail` + SPA `data-fusion-form` |
2628

2729
## Always-on rules
2830

‎.agents/skills/fusion-bindings-parity/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ New public surface → show usage in **Python + Node + C#**. Prefer the same bas
5252
| OpenAPI / Swagger | `app.py` + `api_types.rs` | `buildOpenApi` in `index.js` | `Swagger.cs` |
5353
| Version navbar | per-version OpenAPI routes | same | same |
5454
| Template routes | omit from OpenAPI | omit | omit |
55+
| Template forms | `form` / `ok` / `fail` + `data-fusion-form` | same | `Form` / `Ok` / `Fail` |
5556

5657
## Verification commands
5758

‎.agents/skills/fusion-cli/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ C# (`asp-core`): `main.cs`, `*.csproj` (`net10.0`), `[Route]` / `[HttpGet]`.
118118
### What the starter demonstrates
119119

120120
- `FusionBaseTemplate` at `/` (Tera templates; **not** listed in Swagger).
121+
- HTML forms: `form` / `ok` / `fail` + optional `data-fusion-form` (see skill `fusion-template-forms`).
121122
- 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" %}`).
122123
- `FusionBaseApi` at `api/[module]` with `version="v1"` → `/v1/api/product/…`.
123124
- Convention verbs (`get` / `post` / …) plus one custom slot (`http_get` / `httpGet` / `[HttpGet]` with `[action]`).
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
name: fusion-template-forms
3+
description: >-
4+
Documents FusionBaseTemplate form helpers (form, ok, fail), context vs get vs
5+
post, and SPA-friendly data-fusion-form + fusion/form.js. Use when building
6+
HTML forms that POST to the same page.
7+
---
8+
9+
# Template forms (SPA-friendly)
10+
11+
## Mental model
12+
13+
| Method | Role |
14+
|--------|------|
15+
| `context()` | Template **data** (title, fields). Not an HTTP verb. |
16+
| `get()` | **HTTP GET** — renders `context()` as HTML (or JSON if `Accept: application/json`). Rarely override. |
17+
| `post()` | **HTTP POST** — read `form`, validate with normal `if`, return `ok` / `fail`. |
18+
19+
## API
20+
21+
| Python | Node | C# |
22+
|--------|------|-----|
23+
| `self.form` | `this.form` | `Form` |
24+
| `self.fail(errors, message=..., **fields)` | `this.fail(errors, { message, ...fields })` | `Fail(errors, message, fields)` |
25+
| `self.ok(message=..., **fields)` | `this.ok({ message, ...fields })` | `Ok(message, fields)` |
26+
27+
- JSON clients (`Accept: application/json` or SPA fetch) get `{ ok, message, errors, fields }`.
28+
- HTML clients re-render the **same** template with errors/fields (no separate “success page” required).
29+
30+
## SPA markup
31+
32+
```html
33+
<form method="post" action="/register" data-fusion-form>
34+
<div id="fusion-form-status"></div>
35+
<input name="phone" />
36+
<span class="fusion-field-error" data-field="phone"></span>
37+
<button type="submit">Save</button>
38+
</form>
39+
<script>{% include "fusion/form.js" %}</script>
40+
```
41+
42+
Built-in script lives at `fusion/form.js` (embedded in fusion-core). Without JS, classic POST still works via `ok`/`fail` HTML path.
43+
44+
## Example
45+
46+
```python
47+
def post(self):
48+
form = self.form
49+
if not form.get("phone"):
50+
return self.fail({"phone": "required"}, **form)
51+
return self.ok(message="Saved.", **form)
52+
```
53+
54+
See `examples/template_form.{py,mjs,cs}`.

‎bindings/csharp/FusionFramework/FusionBaseTemplate.cs‎

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,71 @@ public abstract class FusionBaseTemplate : FusionBaseApi
1010
public static string TemplateAddress { get; set; } = "";
1111
public static string TemplatesDir { get; set; } = "";
1212

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

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

23+
/// <summary>Parsed POST body (urlencoded or JSON) as flat string fields.</summary>
24+
public Dictionary<string, string> Form => ParseFormBody(Body, ContentType());
25+
26+
/// <summary>Parse urlencoded or JSON body into flat string fields.</summary>
27+
public static Dictionary<string, string> ParseFormBody(string? body, string? contentType)
28+
{
29+
var raw = body ?? "";
30+
var ct = (contentType ?? "").ToLowerInvariant();
31+
var outDict = new Dictionary<string, string>(StringComparer.Ordinal);
32+
if (ct.Contains("application/json", StringComparison.Ordinal)
33+
|| (raw.TrimStart().StartsWith('{') && !ct.Contains("urlencoded", StringComparison.Ordinal)))
34+
{
35+
try
36+
{
37+
var node = string.IsNullOrWhiteSpace(raw) ? null : JsonNode.Parse(raw);
38+
if (node is JsonObject obj)
39+
{
40+
foreach (var kv in obj)
41+
outDict[kv.Key] = kv.Value is null || kv.Value.GetValueKind() == JsonValueKind.Null
42+
? ""
43+
: kv.Value.ToString() ?? "";
44+
}
45+
}
46+
catch (JsonException)
47+
{
48+
// ignore invalid JSON
49+
}
50+
return outDict;
51+
}
52+
53+
if (string.IsNullOrEmpty(raw))
54+
return outDict;
55+
56+
foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
57+
{
58+
var parts = pair.Split('=', 2);
59+
var key = Uri.UnescapeDataString(parts[0].Replace('+', ' '));
60+
var value = parts.Length > 1
61+
? Uri.UnescapeDataString(parts[1].Replace('+', ' '))
62+
: "";
63+
outDict[key] = value;
64+
}
65+
return outDict;
66+
}
67+
68+
string? ContentType()
69+
{
70+
foreach (var kv in Request.Headers)
71+
{
72+
if (string.Equals(kv.Key, "Content-Type", StringComparison.OrdinalIgnoreCase))
73+
return kv.Value;
74+
}
75+
return null;
76+
}
77+
2078
/// <summary>
2179
/// Default GET — HTML or JSON context. Uses <see cref="ContextAsync"/> so
2280
/// subclasses can override that for DB/API-backed pages (Python async context parity).
@@ -42,6 +100,103 @@ object FinishGet(Dictionary<string, JsonNode?> ctx)
42100
return HtmlResponse(ctx);
43101
}
44102

103+
/// <summary>Validation failure — JSON for SPA fetch, else same template with errors.</summary>
104+
public object Fail(
105+
IDictionary<string, string>? errors = null,
106+
string? message = null,
107+
IDictionary<string, string>? fields = null)
108+
{
109+
var err = errors?.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal)
110+
?? new Dictionary<string, string>(StringComparer.Ordinal);
111+
var flat = fields?.ToDictionary(
112+
kv => kv.Key,
113+
kv => kv.Value ?? "",
114+
StringComparer.Ordinal)
115+
?? new Dictionary<string, string>(StringComparer.Ordinal);
116+
var msg = message ?? "Validation failed";
117+
if (WantsJson())
118+
{
119+
return Response(new Dictionary<string, object?>
120+
{
121+
["ok"] = false,
122+
["message"] = msg,
123+
["errors"] = err,
124+
["fields"] = flat,
125+
}, 400);
126+
}
127+
return FormHtmlResult(ok: false, message: msg, errors: err, fields: flat, status: 400);
128+
}
129+
130+
/// <summary>Success — JSON for SPA fetch, else same template with ok=true.</summary>
131+
public object Ok(string? message = null, IDictionary<string, string>? fields = null)
132+
{
133+
var flat = fields?.ToDictionary(
134+
kv => kv.Key,
135+
kv => kv.Value ?? "",
136+
StringComparer.Ordinal)
137+
?? new Dictionary<string, string>(StringComparer.Ordinal);
138+
var msg = message ?? "OK";
139+
if (WantsJson())
140+
{
141+
return Response(new Dictionary<string, object?>
142+
{
143+
["ok"] = true,
144+
["message"] = msg,
145+
["errors"] = new Dictionary<string, string>(),
146+
["fields"] = flat,
147+
}, 200);
148+
}
149+
return FormHtmlResult(
150+
ok: true,
151+
message: msg,
152+
errors: new Dictionary<string, string>(),
153+
fields: flat,
154+
status: 200);
155+
}
156+
157+
object FormHtmlResult(
158+
bool ok,
159+
string message,
160+
IDictionary<string, string> errors,
161+
IDictionary<string, string> fields,
162+
int status)
163+
{
164+
var task = ContextAsync();
165+
if (!task.IsCompletedSuccessfully)
166+
return FormHtmlResultAsync(task, ok, message, errors, fields, status);
167+
return FinishFormHtml(task.Result, ok, message, errors, fields, status);
168+
}
169+
170+
async Task<object> FormHtmlResultAsync(
171+
Task<Dictionary<string, JsonNode?>> task,
172+
bool ok,
173+
string message,
174+
IDictionary<string, string> errors,
175+
IDictionary<string, string> fields,
176+
int status)
177+
{
178+
var ctx = await task.ConfigureAwait(false);
179+
return FinishFormHtml(ctx, ok, message, errors, fields, status);
180+
}
181+
182+
object FinishFormHtml(
183+
Dictionary<string, JsonNode?> ctx,
184+
bool ok,
185+
string message,
186+
IDictionary<string, string> errors,
187+
IDictionary<string, string> fields,
188+
int status)
189+
{
190+
var data = new Dictionary<string, JsonNode?>(ctx, StringComparer.Ordinal);
191+
foreach (var kv in fields)
192+
data[kv.Key] = JsonValue.Create(kv.Value);
193+
data["ok"] = JsonValue.Create(ok);
194+
data["message"] = JsonValue.Create(message);
195+
data["errors"] = JsonSerializer.SerializeToNode(errors);
196+
data["fields"] = JsonSerializer.SerializeToNode(fields);
197+
return HtmlResponse(data, status);
198+
}
199+
45200
public virtual string TemplateName()
46201
{
47202
var name = !string.IsNullOrEmpty(Template) ? Template : TemplateAddress;
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Progressive-enhancement forms for Fusion templates.
3+
*
4+
* Usage:
5+
* <form data-fusion-form method="post" action="/register">
6+
* <div id="fusion-form-status"></div>
7+
* <input name="phone" />
8+
* <span class="fusion-field-error" data-field="phone"></span>
9+
* </form>
10+
* <script> /* include builtin fusion/form.js here */ </script>
11+
*
12+
* Posts with Accept: application/json and paints ok/errors without leaving the page.
13+
* Without JS, the browser does a normal HTML POST (server still uses ok/fail).
14+
*/
15+
16+
(function () {
17+
function clearErrors(form) {
18+
form.querySelectorAll(".fusion-field-error").forEach(function (el) {
19+
el.textContent = "";
20+
});
21+
var status = form.querySelector("#fusion-form-status") || document.getElementById("fusion-form-status");
22+
if (status) {
23+
status.textContent = "";
24+
status.classList.remove("fusion-form-ok", "fusion-form-fail");
25+
}
26+
}
27+
28+
function paintErrors(form, errors) {
29+
if (!errors || typeof errors !== "object") return;
30+
Object.keys(errors).forEach(function (key) {
31+
var el =
32+
form.querySelector('.fusion-field-error[data-field="' + key + '"]') ||
33+
document.querySelector('.fusion-field-error[data-field="' + key + '"]');
34+
if (el) el.textContent = String(errors[key] || "");
35+
});
36+
}
37+
38+
function paintStatus(form, message, ok) {
39+
var status = form.querySelector("#fusion-form-status") || document.getElementById("fusion-form-status");
40+
if (!status) return;
41+
status.textContent = message || (ok ? "OK" : "Error");
42+
status.classList.toggle("fusion-form-ok", !!ok);
43+
status.classList.toggle("fusion-form-fail", !ok);
44+
}
45+
46+
function fillFields(form, fields) {
47+
if (!fields || typeof fields !== "object") return;
48+
Object.keys(fields).forEach(function (key) {
49+
var input = form.querySelector('[name="' + key + '"]');
50+
if (!input || input.type === "password") return;
51+
input.value = fields[key] == null ? "" : String(fields[key]);
52+
});
53+
}
54+
55+
document.addEventListener(
56+
"submit",
57+
function (event) {
58+
var form = event.target;
59+
if (!form || !form.getAttribute || !form.hasAttribute("data-fusion-form")) return;
60+
event.preventDefault();
61+
clearErrors(form);
62+
63+
var action = form.getAttribute("action") || window.location.pathname;
64+
var method = (form.getAttribute("method") || "post").toUpperCase();
65+
var body = new URLSearchParams(new FormData(form));
66+
67+
fetch(action, {
68+
method: method,
69+
headers: {
70+
Accept: "application/json",
71+
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
72+
},
73+
body: body.toString(),
74+
credentials: "same-origin",
75+
})
76+
.then(function (res) {
77+
return res.json().then(function (data) {
78+
return { res: res, data: data };
79+
});
80+
})
81+
.then(function (payload) {
82+
var data = payload.data || {};
83+
fillFields(form, data.fields);
84+
if (data.ok) {
85+
paintStatus(form, data.message || "Saved", true);
86+
form.dispatchEvent(new CustomEvent("fusion:form-ok", { detail: data }));
87+
return;
88+
}
89+
paintErrors(form, data.errors);
90+
paintStatus(form, data.message || "Validation failed", false);
91+
form.dispatchEvent(new CustomEvent("fusion:form-fail", { detail: data }));
92+
})
93+
.catch(function () {
94+
paintStatus(form, "Request failed", false);
95+
});
96+
},
97+
true
98+
);
99+
})();

‎crates/fusion-core/src/templates.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const BUILTIN_BASE: &str = include_str!("../assets/templates/fusion/base.html");
1212
const BUILTIN_COMPONENTS_CSS: &str =
1313
include_str!("../assets/templates/fusion/components.css");
1414
const BUILTIN_MONITOR: &str = include_str!("../assets/templates/fusion/monitor.html");
15+
const BUILTIN_FORM_JS: &str = include_str!("../assets/templates/fusion/form.js");
1516

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

@@ -74,6 +75,10 @@ fn build_engine(root: &Path) -> Result<Tera, String> {
7475
"fusion/cache_monitor.html".to_string(),
7576
BUILTIN_MONITOR.to_string(),
7677
),
78+
(
79+
"fusion/form.js".to_string(),
80+
BUILTIN_FORM_JS.to_string(),
81+
),
7782
];
7883

7984
if root.is_dir() {

0 commit comments

Comments
 (0)