From d967c3e7a599597aeb786c35216b16e15e930b87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 01:21:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(ogar-render-askama):=20T4=20=E2=80=94=20Ht?= =?UTF-8?q?mlForm=20(Redmine=20=5Fform.html.erb=20on=20ClassView)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth real emitter per Northstar plan §3. Mirror of Redmine's _form.html.erb shape: one shared form template per resource, here substrate-agnostic via the codebook + ClassView. Same plumbing as T2/T3 with a new dimension (InputKind for form-input controls, parallel to ColumnKind for cell formatters). Surface (additive): - ArtifactKind::HtmlForm variant (slot 3, append-only). ALL.len() now 7. - form_view::InputKind — 9-variant enum: Text, TextArea, Number, Range, Checkbox, Date, DateTime, Select, Hidden. Append-only; template stems stable. - form_view::default_input_kind_for(name, type_name) — resolver mirroring list_view::default_kind_for. id/*_id → Hidden, *_ratio → Range, text+prose → TextArea, boolean → Checkbox, date/datetime → date inputs, numeric Rails types → Number, else → Text. - artifact_kinds::inputs — one binding-struct + askama template per InputKind (9 total). Each escape="html" so per-input variables are HTML-escaped; spine concats with |safe. - artifact_kinds::inputs::InputData — owned data variant the caller supplies, keyed by InputKind. Carries value, required, options, etc. - artifact_kinds::html_form::FormFieldSource + FormSource — the public call-site shapes for one field and the whole form. - artifact_kinds::html_form::render_form(class_id, concept, &FormSource) — the substantive entry point. - HtmlFormEmitter — codebook-only proof-of-shape for the for_kind dispatch path; synthesises a new-record form from class attributes. - empty_input_for(InputKind) — convenience for callers that want a blank form. - templates/dispatch/html_form.askama — spine;
+
+ per-field
wrappers (hidden fields emitted bare without wrapper). - 9 templates/dispatch/input/*.askama: text, textarea, number, range, checkbox, date, datetime, select, hidden. Refactor — cell-dispatch factored: - New artifact_kinds::cells::render_cell_body(&CellData) → String — shared per-kind dispatch. T2's render_cell and T3's render_cell_body now call it. T4 follows the same pattern with InputKind via inputs::render_input_body. "Three points form a line" (Northstar §1.6 notice from #84). Tests (+13, 38/38 total): - form_view::tests (8): template stems stable, html_input_type None-vs-Some, every default_input_kind_for arm (Hidden / Range / TextArea / Number / Checkbox / Date / DateTime / Text fallback). - html_form_proof_of_shape_renders_inputs_for_class_attributes — proof of shape on project_role; data-class-id="0x0117" + per-attribute form-field- wrappers + POST/Create defaults. - html_form_dispatches_input_kinds_to_their_sub_templates — explicit field per kind; pins every , textarea, select option selected="…", checkbox hidden-zero idiom, date/datetime types, hidden-without-wrapper, edit-form record_id, CSRF token, legend, submit/cancel buttons. - html_form_escapes_data_derived_strings_xss_regression — same XSS contract as T2/T3 spines: label / hint / css_classes / action / legend / submit_label / values / placeholder / csrf_token all escape; action="/" → action="/<bad-action>". - input_kind_resolver_is_wired_through_render_kit — pin the public re-export. - artifact_kind_all_const_enumerates_every_variant — ALL.len() 6 → 7. Workspace check + workspace test green. 4/7 real emitters now (T1 RustStruct, T2 HtmlListView, T3 HtmlDetailView, T4 HtmlForm); 3 stubbed for T5 (SurrealqlTable) and the two roadmap-only / deprecated variants. --- .../src/artifact_kinds/cells.rs | 80 ++++- .../src/artifact_kinds/html_detail_view.rs | 47 +-- .../src/artifact_kinds/html_form.rs | 273 ++++++++++++++++ .../src/artifact_kinds/html_list_view.rs | 44 +-- .../src/artifact_kinds/inputs.rs | 298 ++++++++++++++++++ .../src/artifact_kinds/mod.rs | 17 +- crates/ogar-render-askama/src/form_view.rs | 207 ++++++++++++ crates/ogar-render-askama/src/lib.rs | 260 ++++++++++++++- crates/ogar-render-askama/src/spec.rs | 7 + .../templates/dispatch/html_form.askama | 36 +++ .../templates/dispatch/input/checkbox.askama | 4 + .../templates/dispatch/input/date.askama | 2 + .../templates/dispatch/input/datetime.askama | 2 + .../templates/dispatch/input/hidden.askama | 2 + .../templates/dispatch/input/number.askama | 2 + .../templates/dispatch/input/range.askama | 3 + .../templates/dispatch/input/select.askama | 9 + .../templates/dispatch/input/text.askama | 2 + .../templates/dispatch/input/textarea.askama | 2 + 19 files changed, 1201 insertions(+), 96 deletions(-) create mode 100644 crates/ogar-render-askama/src/artifact_kinds/html_form.rs create mode 100644 crates/ogar-render-askama/src/artifact_kinds/inputs.rs create mode 100644 crates/ogar-render-askama/src/form_view.rs create mode 100644 crates/ogar-render-askama/templates/dispatch/html_form.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/input/checkbox.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/input/date.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/input/datetime.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/input/hidden.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/input/number.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/input/range.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/input/select.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/input/text.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/input/textarea.askama diff --git a/crates/ogar-render-askama/src/artifact_kinds/cells.rs b/crates/ogar-render-askama/src/artifact_kinds/cells.rs index 67303cc..147b0b1 100644 --- a/crates/ogar-render-askama/src/artifact_kinds/cells.rs +++ b/crates/ogar-render-askama/src/artifact_kinds/cells.rs @@ -1,18 +1,21 @@ //! Per-[`ColumnKind`](crate::ColumnKind) cell renderers — one askama -//! sub-template per variant. Called from -//! [`html_list_view`](super::html_list_view) at row-build time to -//! pre-render each cell's body, so the spine template just emits -//! `{{ cell.body_html|safe }}` with no runtime polymorphism. +//! sub-template per variant. Called from the list-view + detail-view +//! emitters at row-build time to pre-render each cell's body, so the +//! spine templates just emit `{{ cell.body_html|safe }}` with no +//! runtime polymorphism. //! -//! Per Northstar §1.6 (mass-mail templates): each cell template is the -//! smallest bag of variables it needs (the `*Cell` binding-structs -//! below). Different cell kinds don't share a binding-struct. +//! [`render_cell_body`] is the shared dispatch entry point T2 / T3 / T4 +//! all call (factored when T4 became the third caller — see Northstar +//! plan §1.6: "templates are mass-mail simple; the *binding-struct* is +//! the bag of variables; the dispatch is a Rust `match`"). //! //! The catalog mirrors Redmine `queries_helper::column_value`'s 12-arm //! case (see `docs/integration/REDMINE-QUERY-HARVEST.md` §1.4). use askama::Template; +use super::html_list_view::CellData; + // ── Plain ──────────────────────────────────────────────────────────── #[derive(Template)] @@ -119,3 +122,66 @@ pub(crate) struct UserEntry<'a> { pub name: &'a str, pub href: &'a str, } + +// ── Shared dispatch — one place T2 / T3 / T4 call ───────────────────── + +/// Pre-render a [`CellData`] value into its HTML body via the matching +/// per-kind sub-template. The spine templates (`html_list_view`, +/// `html_detail_view`, `html_form`'s read-only fallback paths) consume +/// the resulting string with `|safe` because the sub-templates already +/// escape their own variables. +/// +/// Factored from the duplicated `render_cell_body` helpers in T2 and T3 +/// when T4 became the third caller. Northstar plan §1.6: three points +/// form a line. +pub(crate) fn render_cell_body(data: &CellData<'_>) -> Result { + Ok(match data { + CellData::Plain { value } => PlainCell { value }.render()?, + CellData::IdLink { id, href } => IdLinkCell { id: *id, href }.render()?, + CellData::PrimaryLink { label, href } => PrimaryLinkCell { label, href }.render()?, + CellData::RecordRef { + label, + href, + target_concept, + } => RecordRefCell { + label, + href, + target_concept, + } + .render()?, + CellData::RichText { body } => RichTextCell { body }.render()?, + CellData::ProgressBar { pct } => ProgressBarCell { pct: *pct }.render()?, + CellData::RelationList { relations } => { + let mapped: Vec> = relations + .iter() + .map(|r| RelationEntry { + id: r.id, + kind: r.kind.as_str(), + href: r.href.as_str(), + }) + .collect(); + RelationListCell { relations: mapped }.render()? + } + CellData::Hours { hours, href } => HoursCell { hours, href }.render()?, + CellData::AttachmentList { attachments } => { + let mapped: Vec> = attachments + .iter() + .map(|a| AttachmentEntry { + filename: a.filename.as_str(), + href: a.href.as_str(), + }) + .collect(); + AttachmentListCell { attachments: mapped }.render()? + } + CellData::UserList { users } => { + let mapped: Vec> = users + .iter() + .map(|u| UserEntry { + name: u.name.as_str(), + href: u.href.as_str(), + }) + .collect(); + UserListCell { users: mapped }.render()? + } + }) +} diff --git a/crates/ogar-render-askama/src/artifact_kinds/html_detail_view.rs b/crates/ogar-render-askama/src/artifact_kinds/html_detail_view.rs index e13147a..081eb84 100644 --- a/crates/ogar-render-askama/src/artifact_kinds/html_detail_view.rs +++ b/crates/ogar-render-askama/src/artifact_kinds/html_detail_view.rs @@ -13,11 +13,6 @@ use askama::Template; -use super::cells::{ - AttachmentEntry, AttachmentListCell, HoursCell, IdLinkCell, PlainCell, PrimaryLinkCell, - ProgressBarCell, RecordRefCell, RelationEntry, RelationListCell, RichTextCell, UserEntry, - UserListCell, -}; use super::html_list_view::{CellData, CellSource}; use super::ArtifactEmitter; use crate::list_view::{ColumnKind, RenderColumn}; @@ -116,45 +111,11 @@ pub fn render_detail( .render() } -/// Same per-kind dispatch as the list view (kept duplicated here rather -/// than re-exported because the list-view side has crate-internal naming; -/// once T3 + T4 stabilise we factor the dispatch into one helper). +/// Pre-render this cell's body via the shared per-kind dispatch. +/// See [`super::cells::render_cell_body`]; factored when T4 became the +/// third caller (T2 list-view, T3 detail-view, T4 form-view fallback). fn render_cell_body(src: &CellSource<'_>) -> Result { - Ok(match &src.data { - CellData::Plain { value } => PlainCell { value }.render()?, - CellData::IdLink { id, href } => IdLinkCell { id: *id, href }.render()?, - CellData::PrimaryLink { label, href } => PrimaryLinkCell { label, href }.render()?, - CellData::RecordRef { label, href, target_concept } => RecordRefCell { - label, - href, - target_concept, - } - .render()?, - CellData::RichText { body } => RichTextCell { body }.render()?, - CellData::ProgressBar { pct } => ProgressBarCell { pct: *pct }.render()?, - CellData::RelationList { relations } => { - let mapped: Vec> = relations - .iter() - .map(|r| RelationEntry { id: r.id, kind: r.kind.as_str(), href: r.href.as_str() }) - .collect(); - RelationListCell { relations: mapped }.render()? - } - CellData::Hours { hours, href } => HoursCell { hours, href }.render()?, - CellData::AttachmentList { attachments } => { - let mapped: Vec> = attachments - .iter() - .map(|a| AttachmentEntry { filename: a.filename.as_str(), href: a.href.as_str() }) - .collect(); - AttachmentListCell { attachments: mapped }.render()? - } - CellData::UserList { users } => { - let mapped: Vec> = users - .iter() - .map(|u| UserEntry { name: u.name.as_str(), href: u.href.as_str() }) - .collect(); - UserListCell { users: mapped }.render()? - } - }) + super::cells::render_cell_body(&src.data) } /// The codebook-only dispatch entry point used by [`for_kind`](super::for_kind). diff --git a/crates/ogar-render-askama/src/artifact_kinds/html_form.rs b/crates/ogar-render-askama/src/artifact_kinds/html_form.rs new file mode 100644 index 0000000..e745057 --- /dev/null +++ b/crates/ogar-render-askama/src/artifact_kinds/html_form.rs @@ -0,0 +1,273 @@ +//! `HtmlForm` emitter — T4 per Northstar plan §3. The create/edit-page +//! sibling of T2's list view and T3's detail view. +//! +//! Substrate-agnostic — one form spine template + per-[`InputKind`] +//! sub-templates. Inputs are pre-rendered in Rust (parallel to the +//! T2/T3 cell pattern) so the spine emits `{{ field.body_html|safe }}` +//! with no runtime polymorphism on the askama side. +//! +//! Mirrors Redmine's `_form.html.erb` shape (one shared partial per +//! resource), generalised across every canonical concept by the +//! `InputKind` + `RenderColumn` pair. + +use askama::Template; + +use super::inputs::{render_input_body, InputData}; +use super::ArtifactEmitter; +use crate::form_view::{default_input_kind_for, InputKind}; +use crate::list_view::RenderColumn; +use crate::spec::ArtifactSpec; +use ogar_vocab::canonical_concept_id; + +// ── Spine binding struct ───────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/html_form.askama", escape = "html")] +struct HtmlFormCtx { + class_id_hex: String, + canonical_concept: String, + method: String, + action: String, + csrf_token: String, + /// Pre-rendered hidden `` for the record id (empty when + /// rendering a new-record form). + record_id_html: String, + legend: String, + submit_label: String, + cancel_label: String, + cancel_href: String, + fields: Vec, +} + +struct FormField { + name: String, + label: String, + css_classes: String, + hint: String, + required: bool, + /// `true` → emit the body bare (no `