diff --git a/.agents/README.md b/.agents/README.md index b7c990d..4ab989a 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -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 diff --git a/.agents/skills/fusion-bindings-parity/SKILL.md b/.agents/skills/fusion-bindings-parity/SKILL.md index f536f08..1416d40 100644 --- a/.agents/skills/fusion-bindings-parity/SKILL.md +++ b/.agents/skills/fusion-bindings-parity/SKILL.md @@ -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 diff --git a/.agents/skills/fusion-cli/SKILL.md b/.agents/skills/fusion-cli/SKILL.md index d8b7dff..01a4024 100644 --- a/.agents/skills/fusion-cli/SKILL.md +++ b/.agents/skills/fusion-cli/SKILL.md @@ -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]`). diff --git a/.agents/skills/fusion-template-forms/SKILL.md b/.agents/skills/fusion-template-forms/SKILL.md new file mode 100644 index 0000000..0fc6d78 --- /dev/null +++ b/.agents/skills/fusion-template-forms/SKILL.md @@ -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 +
+
+ + + +
+ +``` + +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}`. diff --git a/bindings/csharp/FusionFramework/FusionBaseTemplate.cs b/bindings/csharp/FusionFramework/FusionBaseTemplate.cs index ad25542..3f1145a 100644 --- a/bindings/csharp/FusionFramework/FusionBaseTemplate.cs +++ b/bindings/csharp/FusionFramework/FusionBaseTemplate.cs @@ -10,13 +10,71 @@ public abstract class FusionBaseTemplate : FusionBaseApi public static string TemplateAddress { get; set; } = ""; public static string TemplatesDir { get; set; } = ""; - /// Sync template variables (override in subclasses). + /// + /// Template variables (not an HTTP verb). Override in subclasses. + /// renders this as HTML; POST handlers use / / . + /// public virtual Dictionary Context() => new(); /// Async template variables; default wraps . public virtual Task> ContextAsync() => Task.FromResult(Context()); + /// Parsed POST body (urlencoded or JSON) as flat string fields. + public Dictionary Form => ParseFormBody(Body, ContentType()); + + /// Parse urlencoded or JSON body into flat string fields. + public static Dictionary ParseFormBody(string? body, string? contentType) + { + var raw = body ?? ""; + var ct = (contentType ?? "").ToLowerInvariant(); + var outDict = new Dictionary(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; + } + /// /// Default GET — HTML or JSON context. Uses so /// subclasses can override that for DB/API-backed pages (Python async context parity). @@ -42,6 +100,103 @@ object FinishGet(Dictionary ctx) return HtmlResponse(ctx); } + /// Validation failure — JSON for SPA fetch, else same template with errors. + public object Fail( + IDictionary? errors = null, + string? message = null, + IDictionary? fields = null) + { + var err = errors?.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + var flat = fields?.ToDictionary( + kv => kv.Key, + kv => kv.Value ?? "", + StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + var msg = message ?? "Validation failed"; + if (WantsJson()) + { + return Response(new Dictionary + { + ["ok"] = false, + ["message"] = msg, + ["errors"] = err, + ["fields"] = flat, + }, 400); + } + return FormHtmlResult(ok: false, message: msg, errors: err, fields: flat, status: 400); + } + + /// Success — JSON for SPA fetch, else same template with ok=true. + public object Ok(string? message = null, IDictionary? fields = null) + { + var flat = fields?.ToDictionary( + kv => kv.Key, + kv => kv.Value ?? "", + StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + var msg = message ?? "OK"; + if (WantsJson()) + { + return Response(new Dictionary + { + ["ok"] = true, + ["message"] = msg, + ["errors"] = new Dictionary(), + ["fields"] = flat, + }, 200); + } + return FormHtmlResult( + ok: true, + message: msg, + errors: new Dictionary(), + fields: flat, + status: 200); + } + + object FormHtmlResult( + bool ok, + string message, + IDictionary errors, + IDictionary 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 FormHtmlResultAsync( + Task> task, + bool ok, + string message, + IDictionary errors, + IDictionary fields, + int status) + { + var ctx = await task.ConfigureAwait(false); + return FinishFormHtml(ctx, ok, message, errors, fields, status); + } + + object FinishFormHtml( + Dictionary ctx, + bool ok, + string message, + IDictionary errors, + IDictionary fields, + int status) + { + var data = new Dictionary(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; diff --git a/crates/fusion-core/assets/templates/fusion/form.js b/crates/fusion-core/assets/templates/fusion/form.js new file mode 100644 index 0000000..3dfdf60 --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/form.js @@ -0,0 +1,99 @@ +/** + * Progressive-enhancement forms for Fusion templates. + * + * Usage: + *
+ *
+ * + * + *
+ * + * + * 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 + ); +})(); diff --git a/crates/fusion-core/src/templates.rs b/crates/fusion-core/src/templates.rs index 9966cde..bcde81f 100644 --- a/crates/fusion-core/src/templates.rs +++ b/crates/fusion-core/src/templates.rs @@ -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> = Mutex::new(None); @@ -74,6 +75,10 @@ fn build_engine(root: &Path) -> Result { "fusion/cache_monitor.html".to_string(), BUILTIN_MONITOR.to_string(), ), + ( + "fusion/form.js".to_string(), + BUILTIN_FORM_JS.to_string(), + ), ]; if root.is_dir() { diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index adb41e3..045f8cf 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -482,17 +482,56 @@ function prefersJsonFallback(accept, formatQuery) { return bestJson > 0 && bestJson >= bestHtml } +function parseFormBody(body, contentType) { + const raw = body == null ? '' : String(body) + const ct = String(contentType || '').toLowerCase() + if (ct.includes('application/json') || (raw.trim().startsWith('{') && !ct.includes('urlencoded'))) { + try { + const data = raw.trim() ? JSON.parse(raw) : {} + if (data && typeof data === 'object' && !Array.isArray(data)) { + const out = {} + for (const [k, v] of Object.entries(data)) out[k] = v == null ? '' : String(v) + return out + } + } catch { + return {} + } + return {} + } + const params = new URLSearchParams(raw) + const out = {} + for (const key of params.keys()) { + out[key] = params.get(key) ?? '' + } + return out +} + class FusionBaseTemplate extends FusionBaseApi { static __fusion_template__ = true static template = '' static templateAddress = '' static templatesDir = '' - /** Template variables; may return a Promise (async context). */ + /** + * Template variables (not an HTTP verb). May return a Promise. + * get() renders this as HTML; post() should use form / ok / fail. + */ context() { return {} } + /** Parsed POST body (urlencoded or JSON) as flat string fields. */ + get form() { + let contentType = null + for (const [key, value] of Object.entries(this.headers || {})) { + if (String(key).toLowerCase() === 'content-type') { + contentType = String(value) + break + } + } + return parseFormBody(this.body, contentType) + } + get() { const raw = this.context() if (raw && typeof raw.then === 'function') { @@ -512,6 +551,57 @@ class FusionBaseTemplate extends FusionBaseApi { return this._htmlResponse(data) } + /** + * Validation failure — JSON for SPA fetch, else same template with errors. + * fail({ phone: 'required' }, { message: 'خطا', ...formFields }) + */ + fail(errors = {}, extras = {}) { + const bag = typeof extras === 'string' ? { message: extras } : { ...(extras || {}) } + const message = bag.message != null ? String(bag.message) : 'Validation failed' + delete bag.message + const err = {} + for (const [k, v] of Object.entries(errors || {})) err[k] = String(v) + const flat = {} + for (const [k, v] of Object.entries(bag)) flat[k] = v == null ? '' : String(v) + + if (this.wantsJson()) { + return this.response({ ok: false, message, errors: err, fields: flat }, 400) + } + return this._formHtmlResult({ ok: false, message, errors: err, fields: flat, status: 400 }) + } + + /** Success — JSON for SPA fetch, else same template with ok=true. */ + ok(extras = {}) { + const bag = typeof extras === 'string' ? { message: extras } : { ...(extras || {}) } + const message = bag.message != null ? String(bag.message) : 'OK' + delete bag.message + const flat = {} + for (const [k, v] of Object.entries(bag)) flat[k] = v == null ? '' : String(v) + + if (this.wantsJson()) { + return this.response({ ok: true, message, errors: {}, fields: flat }, 200) + } + return this._formHtmlResult({ ok: true, message, errors: {}, fields: flat, status: 200 }) + } + + _formHtmlResult({ ok, message, errors, fields, status }) { + const raw = this.context() + if (raw && typeof raw.then === 'function') { + return this._formHtmlResultAsync(raw, { ok, message, errors, fields, status }) + } + return this._finishFormHtml(raw, { ok, message, errors, fields, status }) + } + + async _formHtmlResultAsync(raw, opts) { + const ctx = await raw + return this._finishFormHtml(ctx, opts) + } + + _finishFormHtml(ctx, { ok, message, errors, fields, status }) { + const data = { ...(ctx || {}), ...fields, ok, message, errors: { ...errors }, fields: { ...fields } } + return this._htmlResponse(data, { status }) + } + templateName() { const name = this.constructor.template || this.constructor.templateAddress if (!name) { @@ -1855,6 +1945,7 @@ module.exports = { FusionApp, FusionBaseApi, FusionBaseTemplate, + parseFormBody, HTTPException, router, route, diff --git a/crates/fusion-py/python/fusion_framework/__init__.py b/crates/fusion-py/python/fusion_framework/__init__.py index e6d2a79..d0e980f 100644 --- a/crates/fusion-py/python/fusion_framework/__init__.py +++ b/crates/fusion-py/python/fusion_framework/__init__.py @@ -15,7 +15,7 @@ use, ) from fusion_framework.pagination import PaginationParams, paginated_body, parse_pagination -from fusion_framework.template import FusionBaseTemplate, render_template +from fusion_framework.template import FusionBaseTemplate, parse_form_body, render_template from fusion_framework import cache from fusion_framework import tasks from . import header, status @@ -41,5 +41,6 @@ "parse_pagination", "paginated_body", "FusionBaseTemplate", + "parse_form_body", "render_template", ] diff --git a/crates/fusion-py/python/fusion_framework/template.py b/crates/fusion-py/python/fusion_framework/template.py index c2c7737..2dc0306 100644 --- a/crates/fusion-py/python/fusion_framework/template.py +++ b/crates/fusion-py/python/fusion_framework/template.py @@ -3,8 +3,10 @@ from __future__ import annotations import inspect +import json from pathlib import Path from typing import Any, ClassVar, Mapping, Optional, Union +from urllib.parse import parse_qs from fusion_framework._fusion import render_template as _render_template from fusion_framework.api import FusionBaseApi @@ -22,24 +24,43 @@ def render_template( return _render_template(template_name, dict(context or {}), root) +def parse_form_body(body: str, content_type: str | None = None) -> dict[str, str]: + """Parse urlencoded or JSON body into flat string fields.""" + raw = body or "" + ct = (content_type or "").lower() + if "application/json" in ct or (raw.lstrip().startswith("{") and "urlencoded" not in ct): + try: + data = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + data = {} + if isinstance(data, dict): + return {str(k): "" if v is None else str(v) for k, v in data.items()} + return {} + parsed = parse_qs(raw, keep_blank_values=True) + return {key: (values[0] if values else "") for key, values in parsed.items()} + + class FusionBaseTemplate(FusionBaseApi): """Class-based HTML handler using Tera templates. - Set ``template`` (or ``template_address``) to the file path under the templates - directory. Override ``context()`` to pass variables — sync or ``async def``. - The default ``get()`` renders HTML for browsers and returns ``context()`` as JSON - when the client sends ``Accept: application/json`` or ``?format=json``. + Mental model: - Template routes are mounted as HTTP handlers but are excluded from Swagger/OpenAPI. + - ``context()`` — **template data** (title, fields, …). Not an HTTP verb. + - ``get()`` — **HTTP GET**; renders ``context()`` as HTML (or JSON if client wants JSON). + - ``post()`` — **HTTP POST**; read ``self.form``, validate, return ``ok`` / ``fail``. - Built-in UI components are defined in ``fusion/macros.html`` (Tera 2 components):: + Form helpers (SPA-friendly):: - {{}} - {{}} - {{}} + def post(self): + form = self.form + if not form.get("phone"): + return self.fail({"phone": "required"}, **form) + return self.ok(message="saved", **form) - Include styles with ``{% include "fusion/components.css" %}`` or extend - ``fusion/base.html``. + With ``data-fusion-form`` + ``{% include "fusion/form.js" %}``, the browser + posts JSON and stays on the same page. + + Template routes are excluded from Swagger/OpenAPI. """ __fusion_template__ = True @@ -52,12 +73,18 @@ def context(self) -> Union[dict[str, Any], Any]: """Template variables (override in subclasses; may be ``async def``).""" return {} - def get(self) -> Any: - """Default GET — HTML page, or ``context()`` JSON when client wants JSON. + @property + def form(self) -> dict[str, str]: + """Parsed POST body (urlencoded or JSON) as flat string fields.""" + content_type = None + for key, value in self.headers.items(): + if key.lower() == "content-type": + content_type = str(value) + break + return parse_form_body(self.body, content_type) - Supports sync or async ``context()``; async returns an awaitable for the - framework event loop. - """ + def get(self) -> Any: + """Default GET — HTML page, or ``context()`` JSON when client wants JSON.""" raw = self.context() if inspect.isawaitable(raw): return self._get_async(raw) @@ -75,6 +102,120 @@ def _finish_get(self, ctx: Any) -> dict[str, Any]: return data return self._html_response(data) + def fail( + self, + errors: Mapping[str, str] | None = None, + message: str | None = None, + **fields: Any, + ) -> Any: + """Validation failure — JSON for SPA fetch, else same template with errors. + + Example:: + + return self.fail({"phone": "شماره لازم است"}, message="خطا", **form) + """ + err = {str(k): str(v) for k, v in dict(errors or {}).items()} + form_fields = {str(k): "" if v is None else str(v) for k, v in fields.items()} + payload_message = message or "Validation failed" + if self.wants_json(): + return self.response( + { + "ok": False, + "message": payload_message, + "errors": err, + "fields": form_fields, + }, + status=400, + ) + return self._form_html_result( + ok=False, + message=payload_message, + errors=err, + fields=form_fields, + status=400, + ) + + def ok(self, message: str | None = None, **fields: Any) -> Any: + """Success — JSON for SPA fetch, else same template with ``ok=true``. + + Example:: + + return self.ok(message="ثبت شد", **form) + """ + form_fields = {str(k): "" if v is None else str(v) for k, v in fields.items()} + payload_message = message or "OK" + if self.wants_json(): + return self.response( + { + "ok": True, + "message": payload_message, + "errors": {}, + "fields": form_fields, + }, + status=200, + ) + return self._form_html_result( + ok=True, + message=payload_message, + errors={}, + fields=form_fields, + status=200, + ) + + def _form_html_result( + self, + *, + ok: bool, + message: str, + errors: Mapping[str, str], + fields: Mapping[str, str], + status: int, + ) -> Any: + """Merge form result into ``context()`` and re-render the same template.""" + raw = self.context() + if inspect.isawaitable(raw): + return self._form_html_result_async( + raw, ok=ok, message=message, errors=errors, fields=fields, status=status + ) + return self._finish_form_html( + raw, ok=ok, message=message, errors=errors, fields=fields, status=status + ) + + async def _form_html_result_async( + self, + raw: Any, + *, + ok: bool, + message: str, + errors: Mapping[str, str], + fields: Mapping[str, str], + status: int, + ) -> dict[str, Any]: + """Await async context then finish form HTML.""" + ctx = await raw + return self._finish_form_html( + ctx, ok=ok, message=message, errors=errors, fields=fields, status=status + ) + + def _finish_form_html( + self, + ctx: Any, + *, + ok: bool, + message: str, + errors: Mapping[str, str], + fields: Mapping[str, str], + status: int, + ) -> dict[str, Any]: + """Apply form result onto context and render HTML.""" + data = dict(ctx or {}) + data.update(fields) + data["ok"] = ok + data["message"] = message + data["errors"] = dict(errors) + data["fields"] = dict(fields) + return self._html_response(data, status=status) + def template_name(self) -> str: """Resolved template path (override for dynamic templates).""" name = self.template or self.template_address @@ -168,4 +309,4 @@ def _html_response( return self.response(html, status=status, headers=hdrs) -__all__ = ["FusionBaseTemplate", "render_template"] +__all__ = ["FusionBaseTemplate", "render_template", "parse_form_body"] diff --git a/examples/template_form.cs b/examples/template_form.cs new file mode 100644 index 0000000..481f14b --- /dev/null +++ b/examples/template_form.cs @@ -0,0 +1,51 @@ +// SPA-friendly template form (Form / Ok / Fail). +// Prefer running the Python or Node example for a quick demo; +// this file shows the C# handler shape. +// +// RegisterPage : FusionBaseTemplate +// Context() -> title/message +// Post() -> Form + Fail(errors) / Ok(message, fields) + +using System.Text.Json.Nodes; +using FusionFramework; + +[Route("/register")] +public class RegisterPage : FusionBaseTemplate +{ + static RegisterPage() + { + Template = "register.html"; + } + + public override Dictionary Context() => new() + { + ["title"] = "Register", + ["message"] = "Fill the form.", + ["ok"] = false, + ["errors"] = new JsonObject(), + ["name"] = "", + ["phone"] = "", + }; + + public object Post() + { + var form = Form; + var errors = new Dictionary(StringComparer.Ordinal); + if (string.IsNullOrWhiteSpace(form.GetValueOrDefault("phone"))) + errors["phone"] = "phone is required"; + if (string.IsNullOrWhiteSpace(form.GetValueOrDefault("name"))) + errors["name"] = "name is required"; + + var safe = new Dictionary + { + ["name"] = form.GetValueOrDefault("name") ?? "", + ["phone"] = form.GetValueOrDefault("phone") ?? "", + }; + + if (errors.Count > 0) + return Fail(errors, "Fix the errors.", safe); + + Console.WriteLine($"submitted name={safe["name"]} phone={safe["phone"]}"); + return Ok("Saved.", safe); + } +} diff --git a/examples/template_form.mjs b/examples/template_form.mjs new file mode 100644 index 0000000..f37d487 --- /dev/null +++ b/examples/template_form.mjs @@ -0,0 +1,83 @@ +/** + * SPA-friendly template form (form / ok / fail). + * + * node examples/template_form.mjs + */ +import { createRequire } from 'node:module' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const { + FusionApp, + FusionBaseTemplate, + route, + settings, +} = require('../crates/fusion-node') + +const demoDir = dirname(fileURLToPath(import.meta.url)) +const tplRoot = join(demoDir, 'templates_form') +mkdirSync(tplRoot, { recursive: true }) +writeFileSync( + join(tplRoot, 'register.html'), + ` + +{{ title }} + + + +

{{ title }}

+

{{ message }}

+
+
+ + + +
+ + + +`, + ) + +settings.merge({ templates: { dir: tplRoot } }) + +class RegisterPage extends FusionBaseTemplate { + static template = 'register.html' + + context() { + return { + title: 'Register', + message: 'Fill the form.', + ok: false, + errors: {}, + name: '', + phone: '', + } + } + + post() { + const form = this.form + const errors = {} + if (!form.phone) errors.phone = 'phone is required' + if (!form.name) errors.name = 'name is required' + const safe = { name: form.name || '', phone: form.phone || '' } + if (Object.keys(errors).length) { + return this.fail(errors, { message: 'Fix the errors.', ...safe }) + } + console.log('submitted', safe) + return this.ok({ message: 'Saved.', ...safe }) + } +} + +route('/register')(RegisterPage) + +const app = new FusionApp(settings) +await app.listen() diff --git a/examples/template_form.py b/examples/template_form.py new file mode 100644 index 0000000..3fd5b8d --- /dev/null +++ b/examples/template_form.py @@ -0,0 +1,83 @@ +"""SPA-friendly template form (form / ok / fail). + + python examples/template_form.py + +Open http://127.0.0.1:8080/register +""" + +from __future__ import annotations + +from pathlib import Path + +from fusion_framework import settings +from fusion_framework.app import FusionApp +from fusion_framework.route import route +from fusion_framework.template import FusionBaseTemplate + +DEMO = Path(__file__).resolve().parent +settings.configure(templates={"dir": str(DEMO / "templates_form")}) + + +@route("/register") +class RegisterPage(FusionBaseTemplate): + template = "register.html" + + def context(self): + return { + "title": "Register", + "message": "Fill the form.", + "ok": False, + "errors": {}, + "name": "", + "phone": "", + } + + def post(self): + form = self.form + errors = {} + if not form.get("phone"): + errors["phone"] = "phone is required" + if not form.get("name"): + errors["name"] = "name is required" + safe = {"name": form.get("name", ""), "phone": form.get("phone", "")} + if errors: + return self.fail(errors, message="Fix the errors.", **safe) + print("submitted", safe) + return self.ok(message="Saved.", **safe) + + +def main() -> None: + root = DEMO / "templates_form" + root.mkdir(exist_ok=True) + (root / "register.html").write_text( + """ + +{{ title }} + + + +

{{ title }}

+

{{ message }}

+
+
+ + + +
+ + + +""", + encoding="utf-8", + ) + FusionApp(settings).listen() + + +if __name__ == "__main__": + main() diff --git a/tests/python/unit/test_template_form.py b/tests/python/unit/test_template_form.py new file mode 100644 index 0000000..8c5f018 --- /dev/null +++ b/tests/python/unit/test_template_form.py @@ -0,0 +1,88 @@ +"""Unit tests for template form helpers (form / ok / fail).""" + +from __future__ import annotations + +from fusion_framework.template import FusionBaseTemplate, parse_form_body + + +def test_parse_form_body_urlencoded(): + data = parse_form_body( + "name=Ada&phone=0912", + "application/x-www-form-urlencoded", + ) + assert data["name"] == "Ada" + assert data["phone"] == "0912" + + +def test_parse_form_body_json(): + data = parse_form_body('{"name":"Ada","phone":null}', "application/json") + assert data["name"] == "Ada" + assert data["phone"] == "" + + +class _Page(FusionBaseTemplate): + template = "home/index.html" + + def context(self): + return {"title": "t", "message": "m", "errors": {}, "ok": False} + + +def test_fail_returns_json_when_accept_json(tmp_path, monkeypatch): + monkeypatch.setenv("FUSION_ENV", "dev") + page = _Page( + { + "method": "POST", + "path": "/register", + "body": "phone=", + "headers": { + "accept": "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + "params": {}, + "query": {}, + "state": {}, + } + ) + page.templates_dir = str(tmp_path) + out = page.fail({"phone": "required"}, message="bad", name="Ada") + assert out["status"] == 400 + body = out["body"] + assert body["ok"] is False + assert body["errors"]["phone"] == "required" + assert body["fields"]["name"] == "Ada" + + +def test_ok_returns_json_when_accept_json(tmp_path): + page = _Page( + { + "method": "POST", + "path": "/register", + "body": "", + "headers": {"accept": "application/json"}, + "params": {}, + "query": {}, + "state": {}, + } + ) + page.templates_dir = str(tmp_path) + out = page.ok(message="Saved.", name="Ada") + assert out["status"] == 200 + assert out["body"]["ok"] is True + assert out["body"]["message"] == "Saved." + assert out["body"]["fields"]["name"] == "Ada" + + +def test_form_property_parses_body(): + page = _Page( + { + "method": "POST", + "path": "/register", + "body": "email=a%40b.com&phone=09", + "headers": {"content-type": "application/x-www-form-urlencoded"}, + "params": {}, + "query": {}, + "state": {}, + } + ) + assert page.form["email"] == "a@b.com" + assert page.form["phone"] == "09"