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 `