From 31e51738f8674146566b64154bd3c6d3e74ac6ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 22:25:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(ogar-render-askama):=20T2=20=E2=80=94=20Ht?= =?UTF-8?q?mlListView=20render=20template=20(Redmine=20spine=20on=20ClassV?= =?UTF-8?q?iew=20substrate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second real emitter (render flavour) per Northstar plan §3. Implements the spec calcified in docs/integration/REDMINE-QUERY-HARVEST.md: lifts Redmine's `app/views/issues/_list.html.erb` shape (17 years of organic evolution) onto our ClassView substrate. Apple meets Apple: Redmine spent years iterating to a substrate-agnostic list partial driven by `Query.inline_columns` + `column_content`. T2 inherits that shape with `RenderColumn` + `ColumnKind` + per-kind cell sub-templates pre-rendered in Rust. ArtifactKind: - Replaces `TsInterface` (deprecated in #81 / anti-pattern #8) with `HtmlListView`. - Existing position in `ALL` preserved (slot 1, append-only contract on the indices held). New surface: - `list_view::RenderColumn` — Redmine's QueryColumn 1:1: name, caption, kind, sortable, groupable, totalable, inline, frozen, default_order. Builder-style setters (`.sortable()` / `.block()` / etc.). - `list_view::ColumnKind` — 10 variants (Plain, IdLink, PrimaryLink, RecordRef, RichText, ProgressBar, RelationList, Hours, AttachmentList, UserList). Append-only; template-stem name pinned by test. - `list_view::default_kind_for(name, type_name)` — resolver picking a sensible default kind from a slot's name + curator type (id→IdLink, primary_label→PrimaryLink, *_ratio→ProgressBar, text+prose→RichText, …). - `artifact_kinds::cells` — one binding-struct per ColumnKind, one askama sub-template each (`dispatch/cell/*.askama`). Each is mass-mail simple per Northstar §1.6. - `artifact_kinds::html_list_view::render_list` — the row-data entry point: takes columns + RowSource stream, pre-renders cells in Rust, feeds the spine template `dispatch/html_list_view.askama`. - `HtmlListViewEmitter` — codebook-only emit (proof-of-shape) for the `for_kind` dispatch path; real callers use `render_list` directly. Two-stage rendering: cells are pre-rendered in Rust (per-kind sub- template) → spine template just emits `{{ cell.body_html|safe }}`. No runtime polymorphism on the askama side; askama's compile-time check applies to every binding individually. Tests (+5 for T2, 20/20 total): - artifact_kind_all_const_enumerates_every_variant updated to expect HtmlListView in slot 1. - html_list_view_proof_of_shape_renders_canonical_concept_header — the codebook emit path surfaces data-class-id + data-concept + empty-state. - html_list_view_renders_inline_and_block_rows — full row stream with IdLink, PrimaryLink, ProgressBar, RichText cells; pins the rendered HTML structure (id="record-42", aria-valuenow="70", block-row class, wiki wrapper, etc.). - html_list_view_renders_group_separator_when_provided — group_header data renders `` + name + badge. - default_kind_resolver_is_wired_through_render_kit — pin the public re-export of default_kind_for. - list_view::tests (8 new): RenderColumn builder/defaults, ColumnKind template stems stable, every default_kind_for arm. Workspace check + test green. Per Northstar §1.6, every cell template is the smallest bag of variables it needs; no template hardcodes a class name or a concept-specific slot. --- .../src/artifact_kinds/cells.rs | 121 +++++++ .../src/artifact_kinds/html_list_view.rs | 311 ++++++++++++++++++ .../src/artifact_kinds/mod.rs | 25 +- crates/ogar-render-askama/src/lib.rs | 167 +++++++++- crates/ogar-render-askama/src/list_view.rs | 305 +++++++++++++++++ crates/ogar-render-askama/src/spec.rs | 36 +- .../dispatch/cell/attachment_list.askama | 6 + .../templates/dispatch/cell/hours.askama | 6 + .../templates/dispatch/cell/id_link.askama | 2 + .../templates/dispatch/cell/plain.askama | 2 + .../dispatch/cell/primary_link.askama | 2 + .../dispatch/cell/progress_bar.askama | 5 + .../templates/dispatch/cell/record_ref.askama | 6 + .../dispatch/cell/relation_list.askama | 6 + .../templates/dispatch/cell/rich_text.askama | 4 + .../templates/dispatch/cell/user_list.askama | 6 + .../templates/dispatch/html_list_view.askama | 56 ++++ 17 files changed, 1031 insertions(+), 35 deletions(-) create mode 100644 crates/ogar-render-askama/src/artifact_kinds/cells.rs create mode 100644 crates/ogar-render-askama/src/artifact_kinds/html_list_view.rs create mode 100644 crates/ogar-render-askama/src/list_view.rs create mode 100644 crates/ogar-render-askama/templates/dispatch/cell/attachment_list.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/cell/hours.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/cell/id_link.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/cell/plain.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/cell/primary_link.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/cell/progress_bar.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/cell/record_ref.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/cell/relation_list.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/cell/rich_text.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/cell/user_list.askama create mode 100644 crates/ogar-render-askama/templates/dispatch/html_list_view.askama diff --git a/crates/ogar-render-askama/src/artifact_kinds/cells.rs b/crates/ogar-render-askama/src/artifact_kinds/cells.rs new file mode 100644 index 0000000..67303cc --- /dev/null +++ b/crates/ogar-render-askama/src/artifact_kinds/cells.rs @@ -0,0 +1,121 @@ +//! 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. +//! +//! 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. +//! +//! The catalog mirrors Redmine `queries_helper::column_value`'s 12-arm +//! case (see `docs/integration/REDMINE-QUERY-HARVEST.md` §1.4). + +use askama::Template; + +// ── Plain ──────────────────────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/cell/plain.askama", escape = "html")] +pub(crate) struct PlainCell<'a> { + pub value: &'a str, +} + +// ── IdLink ─────────────────────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/cell/id_link.askama", escape = "html")] +pub(crate) struct IdLinkCell<'a> { + pub id: u64, + pub href: &'a str, +} + +// ── PrimaryLink ────────────────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/cell/primary_link.askama", escape = "html")] +pub(crate) struct PrimaryLinkCell<'a> { + pub label: &'a str, + pub href: &'a str, +} + +// ── RecordRef ──────────────────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/cell/record_ref.askama", escape = "html")] +pub(crate) struct RecordRefCell<'a> { + pub label: &'a str, + pub href: &'a str, + /// Canonical concept name of the target (e.g. `"project"`); surfaced + /// in the `title` attribute for accessibility. + pub target_concept: &'a str, +} + +// ── RichText ───────────────────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/cell/rich_text.askama", escape = "none")] +pub(crate) struct RichTextCell<'a> { + /// Already-rendered prose HTML (markdown / textile expanded + /// upstream; this template just wraps it in `.wiki`). + pub body: &'a str, +} + +// ── ProgressBar ────────────────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/cell/progress_bar.askama", escape = "html")] +pub(crate) struct ProgressBarCell { + pub pct: u8, +} + +// ── RelationList ───────────────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/cell/relation_list.askama", escape = "html")] +pub(crate) struct RelationListCell<'a> { + pub relations: Vec>, +} + +pub(crate) struct RelationEntry<'a> { + pub id: u64, + pub kind: &'a str, + pub href: &'a str, +} + +// ── Hours ──────────────────────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/cell/hours.askama", escape = "html")] +pub(crate) struct HoursCell<'a> { + pub hours: &'a str, + /// Optional link to the underlying time entries (Redmine's + /// `:spent_hours` links to the report); empty if unlinked. + pub href: &'a str, +} + +// ── AttachmentList ─────────────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/cell/attachment_list.askama", escape = "html")] +pub(crate) struct AttachmentListCell<'a> { + pub attachments: Vec>, +} + +pub(crate) struct AttachmentEntry<'a> { + pub filename: &'a str, + pub href: &'a str, +} + +// ── UserList ───────────────────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/cell/user_list.askama", escape = "html")] +pub(crate) struct UserListCell<'a> { + pub users: Vec>, +} + +pub(crate) struct UserEntry<'a> { + pub name: &'a str, + pub href: &'a str, +} diff --git a/crates/ogar-render-askama/src/artifact_kinds/html_list_view.rs b/crates/ogar-render-askama/src/artifact_kinds/html_list_view.rs new file mode 100644 index 0000000..e4b6d9f --- /dev/null +++ b/crates/ogar-render-askama/src/artifact_kinds/html_list_view.rs @@ -0,0 +1,311 @@ +//! `HtmlListView` emitter — T2 per Northstar plan §3, modelled on +//! Redmine's `app/views/issues/_list.html.erb` (the harvest doc captures +//! the design rationale). +//! +//! Substrate-agnostic: nothing in the template knows it's rendering +//! issues vs projects vs users. The columns + rows are pre-shaped by the +//! Rust side; the template iterates and substitutes. +//! +//! # Two-stage rendering +//! +//! Cells are **pre-rendered in Rust** at row-build time using +//! per-[`ColumnKind`] sub-templates from [`super::cells`]. The spine +//! template (`html_list_view.askama`) just emits `{{ cell.body_html|safe }}` +//! — no runtime polymorphism, no dispatch in the template. The +//! per-`ColumnKind` template choice is a `match` in Rust, askama's +//! compile-time check applies to every cell binding individually. + +use askama::Template; + +use super::cells::{ + AttachmentEntry, AttachmentListCell, HoursCell, IdLinkCell, PlainCell, PrimaryLinkCell, + ProgressBarCell, RecordRefCell, RelationEntry, RelationListCell, RichTextCell, UserEntry, + UserListCell, +}; +use super::ArtifactEmitter; +use crate::list_view::{ColumnKind, RenderColumn}; +use crate::spec::ArtifactSpec; +use ogar_vocab::canonical_concept_id; + +// ── Spine binding struct ───────────────────────────────────────────── + +#[derive(Template)] +#[template(path = "dispatch/html_list_view.askama", escape = "none")] +struct HtmlListViewCtx { + title: String, + class_id_hex: String, + canonical_concept: String, + inline_columns: Vec, + /// Block columns aren't iterated in the header (they span the row); + /// kept so the emitter knows which slots to surface as block cells. + /// The template uses `inline_columns.len()` for the colspan only. + rows: Vec, +} + +struct ColumnHeader { + name: String, + caption: String, + sortable: bool, + frozen: bool, + /// Pre-stringified so the template doesn't have to call `.as_str()` + /// on the `SortOrder` enum. + default_order_str: String, +} + +struct HtmlRow { + record_id: u64, + css_classes: String, + /// Optional group separator label. Empty when this row is not the + /// first row of a new group. + group_header_label: String, + group_header_count: u32, + inline_cells: Vec, + block_cells: Vec, +} + +struct RenderedCell { + /// Column name (predicate IRI). Surfaces as `col-` on the ``. + name: String, + css_classes: String, + /// The cell's body — already rendered HTML. + body_html: String, +} + +// ── Cell-source input the emitter expects per (column, row) ────────── + +/// One column×row datum the emitter formats through the right +/// [`ColumnKind`] sub-template. The caller fills these from their own +/// data layer; the emitter handles the askama dispatch. +#[derive(Debug, Clone)] +pub struct CellSource<'a> { + /// The column being rendered. + pub column: &'a RenderColumn, + /// CSS classes to add to the `` (or `block_column` row) — e.g. + /// `"num"` for right-aligned numerics. Defaults to empty. + pub css_classes: &'a str, + /// The raw data for the cell. Per-kind variant determines the + /// per-template binding shape. + pub data: CellData<'a>, +} + +/// Per-kind data the caller supplies for a cell. Variants are keyed by +/// the column's [`ColumnKind`]; the emitter dispatches to the matching +/// askama sub-template at row-build time. +#[derive(Debug, Clone)] +#[allow(missing_docs)] // self-describing variants; fields documented inline. +pub enum CellData<'a> { + /// Plain text fallback — Redmine `format_object`. + Plain { value: &'a str }, + /// Numeric id rendered as a `#` link. + IdLink { id: u64, href: &'a str }, + /// Primary headline link (the row's main column). + PrimaryLink { label: &'a str, href: &'a str }, + /// Family-edge reference rendered as a link to the target record. + RecordRef { + label: &'a str, + href: &'a str, + target_concept: &'a str, + }, + /// Long-form prose — typically rendered as a block-row cell. + RichText { body: &'a str }, + /// Percentage rendered as a progress bar. + ProgressBar { pct: u8 }, + /// List of `project_relation` refs. + RelationList { relations: Vec }, + /// Formatted duration (hours / decimal). + Hours { hours: &'a str, href: &'a str }, + /// List of `project_attachment` refs. + AttachmentList { attachments: Vec }, + /// List of `project_actor` refs (watchers, assignees, …). + UserList { users: Vec }, +} + +/// Owned form of one entry in a `RelationList` cell. +#[derive(Debug, Clone)] +pub struct RelationEntryOwned { + /// Target record id. + pub id: u64, + /// Relation kind (e.g. `"blocks"`, `"duplicates"`). + pub kind: String, + /// Link to the related record. + pub href: String, +} + +/// Owned form of one entry in an `AttachmentList` cell. +#[derive(Debug, Clone)] +pub struct AttachmentEntryOwned { + /// Attachment filename as displayed. + pub filename: String, + /// Download link. + pub href: String, +} + +/// Owned form of one entry in a `UserList` cell. +#[derive(Debug, Clone)] +pub struct UserEntryOwned { + /// Display name. + pub name: String, + /// Link to the user's detail view. + pub href: String, +} + +/// One row's worth of source cells + the row's identifying meta. +#[derive(Debug, Clone)] +pub struct RowSource<'a> { + /// Record id (the canonical row identifier in the source). + pub record_id: u64, + /// CSS classes to add to the row's `` element. + pub css_classes: &'a str, + /// Group-separator data — `Some` only on the first row of a new + /// group. The label is the bucket value; count is the row count in + /// the group (0 → no badge). + pub group: Option>, + /// One entry per inline column, in column order. + pub inline: Vec>, + /// One entry per block column, in column order. + pub block: Vec>, +} + +/// Group-separator data for the first row of a new group. +#[derive(Debug, Clone, Copy)] +pub struct GroupHeader<'a> { + /// The group's bucket value, used as the separator label. + pub label: &'a str, + /// How many rows are in this group (`0` → no badge rendered). + pub count: u32, +} + +// ── The emitter ────────────────────────────────────────────────────── + +/// Render the spine list template against a column set + row stream. +/// +/// Lower-level than the [`ArtifactEmitter::emit`] entry point: takes +/// pre-shaped row sources. Use this when you have your own rows +/// (typical case); [`HtmlListViewEmitter::emit`] is the codebook-only +/// path used by the +5 kit's tests, which renders an empty list as +/// proof of shape. +pub fn render_list( + title: &str, + class_id: u16, + canonical_concept: &str, + inline_columns: &[RenderColumn], + block_columns: &[RenderColumn], + rows: &[RowSource<'_>], +) -> Result { + let inline_headers: Vec = inline_columns + .iter() + .map(|c| ColumnHeader { + name: c.name.clone(), + caption: c.caption.clone(), + sortable: c.sortable, + frozen: c.frozen, + default_order_str: c.default_order.as_str().to_string(), + }) + .collect(); + + let html_rows: Vec = rows + .iter() + .map(|r| HtmlRow { + record_id: r.record_id, + css_classes: r.css_classes.to_string(), + group_header_label: r.group.map(|g| g.label.to_string()).unwrap_or_default(), + group_header_count: r.group.map(|g| g.count).unwrap_or(0), + inline_cells: r + .inline + .iter() + .map(|c| render_cell(c)) + .collect::>() + .unwrap_or_default(), + block_cells: r + .block + .iter() + .map(|c| render_cell(c)) + .collect::>() + .unwrap_or_default(), + }) + .collect(); + let _ = block_columns; // schema documented; block detection is by per-row block cells + + HtmlListViewCtx { + title: title.to_string(), + class_id_hex: format!("0x{class_id:04X}"), + canonical_concept: canonical_concept.to_string(), + inline_columns: inline_headers, + rows: html_rows, + } + .render() +} + +fn render_cell(src: &CellSource<'_>) -> Result { + let body = 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()? + } + }; + Ok(RenderedCell { + name: src.column.name.clone(), + css_classes: src.css_classes.to_string(), + body_html: body, + }) +} + +/// The codebook-only dispatch entry point (used by [`for_kind`](super::for_kind)). +/// Renders the class's canonical fields as headers but no rows — proof +/// of shape for the +5 kit's pipeline. Real callers use [`render_list`] +/// directly with row data. +pub struct HtmlListViewEmitter; + +impl ArtifactEmitter for HtmlListViewEmitter { + fn emit(&self, spec: &ArtifactSpec<'_>) -> Result { + use crate::list_view::default_kind_for; + let class = spec.class; + let concept = class.canonical_concept.as_deref().unwrap_or(""); + let class_id = canonical_concept_id(concept).unwrap_or(0); + + // Synthesise a column set from the class's attributes (inline) + + // a small set of block columns for prose-heavy ones. This is the + // "proof of shape" view used by tests / a future xtask preview. + let mut inline = Vec::new(); + let mut block = Vec::new(); + for attr in &class.attributes { + let kind = default_kind_for(&attr.name, attr.type_name.as_deref()); + let col = RenderColumn::new(&attr.name, &attr.name, kind).sortable(); + if matches!(kind, ColumnKind::RichText) { + block.push(col.block()); + } else { + inline.push(col); + } + } + render_list(class.name.as_str(), class_id, concept, &inline, &block, &[]) + } +} diff --git a/crates/ogar-render-askama/src/artifact_kinds/mod.rs b/crates/ogar-render-askama/src/artifact_kinds/mod.rs index f08e117..f9739cb 100644 --- a/crates/ogar-render-askama/src/artifact_kinds/mod.rs +++ b/crates/ogar-render-askama/src/artifact_kinds/mod.rs @@ -9,18 +9,30 @@ //! let source = emitter.emit(&spec)?; //! ``` //! -//! Proof-of-shape phase: [`RustStruct`](rust_struct::RustStructEmitter) has -//! a real askama template + emitter; the other four kinds use [`Stub`] — -//! placeholder code that compiles and emits a marker comment so callers can -//! exercise the full pipeline (lookup + dispatch + return) without waiting -//! for every template to land. Concrete emitters arrive per-kind in -//! follow-on PRs (T2–T5 in the integration plan). +//! Real emitters (so far): +//! - [`RustStruct`](rust_struct::RustStructEmitter) — T1, codegen flavour, +//! from PR #78. +//! - [`HtmlListView`](html_list_view::HtmlListViewEmitter) — T2, render +//! flavour. Mirrors Redmine's `_list.html.erb` shape on our substrate +//! (see `docs/integration/REDMINE-QUERY-HARVEST.md`). +//! +//! Remaining kinds use [`Stub`] — placeholder code that compiles and +//! emits a marker comment so callers can exercise the full pipeline +//! (lookup + dispatch + return) before T3–T5 land. + +pub(crate) mod cells; use crate::spec::{ArtifactKind, ArtifactSpec}; +pub mod html_list_view; pub mod rust_struct; pub mod stub; +pub use html_list_view::{ + render_list, AttachmentEntryOwned, CellData, CellSource, GroupHeader, HtmlListViewEmitter, + RelationEntryOwned, RowSource, UserEntryOwned, +}; + /// Contract every kind's emitter implements. pub trait ArtifactEmitter { /// Render `spec.class` as the target artifact for this emitter's @@ -34,6 +46,7 @@ pub trait ArtifactEmitter { pub fn for_kind(kind: ArtifactKind) -> Box { match kind { ArtifactKind::RustStruct => Box::new(rust_struct::RustStructEmitter), + ArtifactKind::HtmlListView => Box::new(html_list_view::HtmlListViewEmitter), other => Box::new(stub::Stub { kind: other }), } } diff --git a/crates/ogar-render-askama/src/lib.rs b/crates/ogar-render-askama/src/lib.rs index abe0072..c1a6fb7 100644 --- a/crates/ogar-render-askama/src/lib.rs +++ b/crates/ogar-render-askama/src/lib.rs @@ -56,19 +56,27 @@ //! //! # Proof-of-shape phase //! -//! [`ArtifactKind::RustStruct`] has a real askama template + concrete -//! emitter. The other four kinds use [`artifact_kinds::stub::Stub`] — -//! placeholder code that compiles and emits a marker comment so callers -//! can exercise the full pipeline against every promoted concept while -//! T2–T5 templates land in follow-on PRs. +//! - [`ArtifactKind::RustStruct`] — codegen, real emitter (T1, PR #78). +//! - [`ArtifactKind::HtmlListView`] — render, real emitter (T2, this PR). +//! Spec lifted from `docs/integration/REDMINE-QUERY-HARVEST.md`. +//! - Remaining kinds (`SurrealqlTable`, `OpenapiSchema`, +//! `NodeGuidRoutingArm`) use [`artifact_kinds::stub::Stub`] — +//! placeholder code that compiles and emits a marker comment so +//! callers can exercise the full pipeline (lookup + dispatch + return) +//! against every promoted concept while T3–T5 land. #![forbid(unsafe_code)] #![warn(missing_docs)] pub mod artifact_kinds; +pub mod list_view; pub mod spec; -pub use artifact_kinds::{for_kind, ArtifactEmitter}; +pub use artifact_kinds::{ + for_kind, render_list, ArtifactEmitter, AttachmentEntryOwned, CellData, CellSource, + GroupHeader, RelationEntryOwned, RowSource, UserEntryOwned, +}; +pub use list_view::{default_kind_for, ColumnKind, RenderColumn, SortOrder}; pub use spec::{ArtifactKind, ArtifactSpec}; use ogar_vocab::Class; @@ -117,7 +125,7 @@ mod tests { let all = ArtifactKind::ALL; assert!( all.contains(&ArtifactKind::RustStruct) - && all.contains(&ArtifactKind::TsInterface) + && all.contains(&ArtifactKind::HtmlListView) && all.contains(&ArtifactKind::SurrealqlTable) && all.contains(&ArtifactKind::OpenapiSchema) && all.contains(&ArtifactKind::NodeGuidRoutingArm), @@ -179,12 +187,11 @@ mod tests { #[test] fn stub_emits_marker_for_unimplemented_kinds() { - // The four stub kinds compile + emit a marker comment naming the - // kind + class. This is what lets the kit be wired end-to-end - // before every template has landed. + // Three stub kinds remain after T1+T2 (RustStruct + HtmlListView) + // landed real emitters. Each compiles + emits a marker comment + // naming the kind + class. let class = project(); for kind in [ - ArtifactKind::TsInterface, ArtifactKind::SurrealqlTable, ArtifactKind::OpenapiSchema, ArtifactKind::NodeGuidRoutingArm, @@ -202,6 +209,144 @@ mod tests { } } + // ── T2 (HtmlListView) tests ───────────────────────────────────── + + #[test] + fn html_list_view_proof_of_shape_renders_canonical_concept_header() { + // The codebook-only emit path: no rows, just the class shell. + // Pins that the spine template is wired and surfaces the + // class_id + concept as data-attributes for downstream JS hooks. + let class = project_work_item(); + let src = render(&class, ArtifactKind::HtmlListView).unwrap(); + assert!( + src.contains("data-class-id=\"0x0102\""), + "expected data-class-id=\"0x0102\" in:\n{src}" + ); + assert!( + src.contains("data-concept=\"project_work_item\""), + "{src}" + ); + // Empty-state row appears when no rows are supplied. + assert!(src.contains("No data."), "expected empty-state in:\n{src}"); + } + + #[test] + fn html_list_view_renders_inline_and_block_rows() { + // The substantive path: build columns + rows and assert the + // spine template substitutes them correctly. + let inline = vec![ + RenderColumn::new("id", "#", ColumnKind::IdLink).sortable().frozen(), + RenderColumn::new("subject", "Subject", ColumnKind::PrimaryLink).sortable(), + RenderColumn::new("done_ratio", "% Done", ColumnKind::ProgressBar), + ]; + let block = vec![ + RenderColumn::new("description", "Description", ColumnKind::RichText).block(), + ]; + + let row = RowSource { + record_id: 42, + css_classes: "odd issue closed", + group: None, + inline: vec![ + CellSource { + column: &inline[0], + css_classes: "num", + data: CellData::IdLink { id: 42, href: "/issues/42" }, + }, + CellSource { + column: &inline[1], + css_classes: "", + data: CellData::PrimaryLink { + label: "Fix the foo", + href: "/issues/42", + }, + }, + CellSource { + column: &inline[2], + css_classes: "", + data: CellData::ProgressBar { pct: 70 }, + }, + ], + block: vec![CellSource { + column: &block[0], + css_classes: "", + data: CellData::RichText { + body: "

Some rendered prose.

", + }, + }], + }; + + let src = render_list( + "Work items", + 0x0102, + "project_work_item", + &inline, + &block, + std::slice::from_ref(&row), + ) + .unwrap(); + + // Header + columns + assert!(src.contains("

Work items

"), "{src}"); + assert!(src.contains("data-class-id=\"0x0102\"")); + assert!(src.contains("Subject"), "expected `Subject` column header in:\n{src}"); + assert!(src.contains("% Done")); + // Inline row + cells + assert!(src.contains("id=\"record-42\""), "expected id=\"record-42\":\n{src}"); + assert!(src.contains("href=\"/issues/42\""), "{src}"); + assert!(src.contains("#42"), "id link body should be `#42`:\n{src}"); + assert!(src.contains("Fix the foo"), "{src}"); + // Progress bar + assert!(src.contains("aria-valuenow=\"70\""), "{src}"); + assert!(src.contains("width: 70%"), "{src}"); + // Block row + assert!( + src.contains("class=\"odd issue closed block-row\""), + "expected block row CSS in:\n{src}" + ); + assert!( + src.contains("class=\"wiki\""), + "rich-text wrapper missing in:\n{src}" + ); + assert!(src.contains("Some rendered prose."), "{src}"); + } + + #[test] + fn html_list_view_renders_group_separator_when_provided() { + let col = RenderColumn::new("subject", "Subject", ColumnKind::PrimaryLink); + let row = RowSource { + record_id: 1, + css_classes: "", + group: Some(GroupHeader { label: "Open", count: 5 }), + inline: vec![CellSource { + column: &col, + css_classes: "", + data: CellData::PrimaryLink { label: "T1", href: "/i/1" }, + }], + block: vec![], + }; + let src = render_list("By status", 0x0102, "project_work_item", &[col.clone()], &[], &[row]) + .unwrap(); + assert!(src.contains("class=\"group open\""), "{src}"); + assert!(src.contains("Open"), "{src}"); + assert!(src.contains("5"), "{src}"); + } + + #[test] + fn default_kind_resolver_is_wired_through_render_kit() { + // Smoke: the resolver lib.rs re-exports is the one consumers + // call to pick cell kinds. Pin the contract. + assert_eq!(default_kind_for("id", None), ColumnKind::IdLink); + assert_eq!(default_kind_for("subject", Some("string")), ColumnKind::PrimaryLink); + assert_eq!(default_kind_for("done_ratio", None), ColumnKind::ProgressBar); + assert_eq!(default_kind_for("estimated_hours", None), ColumnKind::Hours); + assert_eq!( + default_kind_for("description", Some("text")), + ColumnKind::RichText + ); + assert_eq!(default_kind_for("position", Some("integer")), ColumnKind::Plain); + } + #[test] fn rust_struct_escapes_keyword_attribute_names() { // Codex P1 on #78: `project_actor()` declares an attribute named diff --git a/crates/ogar-render-askama/src/list_view.rs b/crates/ogar-render-askama/src/list_view.rs new file mode 100644 index 0000000..cd35597 --- /dev/null +++ b/crates/ogar-render-askama/src/list_view.rs @@ -0,0 +1,305 @@ +//! Shared types for the `HtmlListView` emitter (T2 per Northstar §3). +//! +//! Lifted from the Redmine Query/QueryColumn harvest +//! (`docs/integration/REDMINE-QUERY-HARVEST.md` §3). [`RenderColumn`] +//! mirrors `QueryColumn`'s 8 properties one-to-one; [`ColumnKind`] mirrors +//! `column_value`'s case-statement dispatch as a closed enum (one askama +//! sub-template per variant). +//! +//! Per Northstar plan §1.3 (`Class` carries types; `ClassView` carries +//! labels): the render-meta on `RenderColumn` is *presentation intent* — +//! it lives in this binding-struct sidecar, NOT on the contract +//! `FieldRef`. Anti-pattern #2 (the bicycle) territory if it migrated up. + +/// One column in a tabular list view — Redmine's `QueryColumn` mapped +/// onto our substrate (canonical concept fields + presentation meta). +/// +/// 8 properties to match the Redmine model. `name` and `caption` are the +/// only fields a minimal column needs; the rest tune presentation. +#[derive(Debug, Clone)] +pub struct RenderColumn { + /// Field name on the underlying record (predicate IRI for the + /// canonical layer; matches `FieldRef::predicate_iri`). + pub name: String, + /// Display label for the column header. + pub caption: String, + /// Cell-formatter dispatch — which `dispatch/cell/*.askama` + /// sub-template renders this column's body. + pub kind: ColumnKind, + /// Whether the column header carries a sort link. + pub sortable: bool, + /// Whether the column can be used as the group-by axis. + pub groupable: bool, + /// Whether per-group / overall totals are meaningful (numeric kinds). + pub totalable: bool, + /// `true` → `` in the row; `false` → `` + /// that spans the full row width (description, last_notes, …). + pub inline: bool, + /// Always visible regardless of user's column selection (typically + /// the id / primary-link column). + pub frozen: bool, + /// Initial sort direction when this column becomes the active sort. + pub default_order: SortOrder, +} + +impl RenderColumn { + /// Conservative default: a plain text inline column, no sort/group/ + /// total, ascending. Use builder-style setters to refine. + #[must_use] + pub fn new(name: impl Into, caption: impl Into, kind: ColumnKind) -> Self { + Self { + name: name.into(), + caption: caption.into(), + kind, + sortable: false, + groupable: false, + totalable: false, + inline: true, + frozen: false, + default_order: SortOrder::Asc, + } + } + + /// Mark this column as sortable, defaulting to ascending order. + #[must_use] + pub fn sortable(mut self) -> Self { + self.sortable = true; + self + } + + /// Mark this column as a candidate group-by axis. + #[must_use] + pub fn groupable(mut self) -> Self { + self.groupable = true; + self + } + + /// Mark this column as totalable (numeric aggregation). + #[must_use] + pub fn totalable(mut self) -> Self { + self.totalable = true; + self + } + + /// Promote to a block column (spans the full row width, below the + /// inline cells). Used for prose-heavy fields like description. + #[must_use] + pub fn block(mut self) -> Self { + self.inline = false; + self + } + + /// Mark this column as frozen — always rendered regardless of the + /// user's column selection. + #[must_use] + pub fn frozen(mut self) -> Self { + self.frozen = true; + self + } + + /// Override the default sort order applied when the column first + /// becomes the active sort. + #[must_use] + pub fn default_order(mut self, order: SortOrder) -> Self { + self.default_order = order; + self + } +} + +/// Cell-formatter dispatch. One askama sub-template per variant +/// (`dispatch/cell/.askama`). **Append-only** — new variants are +/// added at the end; existing variants are never reordered or repurposed +/// (downstream consumers may have stored selections keyed on names). +/// +/// Lifted from Redmine `column_value`'s 12-arm `case` (see +/// `queries_helper.rb`). Maps each formatter to a typed enum so the +/// emitter dispatches in Rust (zero polymorphism on the askama side). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ColumnKind { + /// Plain text — `format_object` fallback in Redmine. Default kind + /// for unspecialised attributes. + Plain, + /// The record's canonical id rendered as a `#` link to its detail + /// view. Redmine: `when :id then link_to value, issue_path(item)`. + IdLink, + /// The primary-label column rendered as a link to the detail view — + /// the "headline" column (Redmine's `:subject`). + PrimaryLink, + /// A family-edge reference rendered as a link to the target record + /// (Redmine's `:parent` arm, `link_to_issue` calls). + RecordRef, + /// Long-form prose with markup (Markdown/Textile) — typically used in + /// block columns. Redmine: `:description`, `:last_notes`. + RichText, + /// Percentage-as-progress-bar. Redmine: `:done_ratio`. + ProgressBar, + /// List of `project_relation` links — render-time scan of incoming / + /// outgoing relations. + RelationList, + /// Formatted duration in hours / decimal. Redmine: `:estimated_hours`, + /// `:spent_hours`, `:total_*_hours`. + Hours, + /// List of `project_attachment` refs. + AttachmentList, + /// List of `project_actor` refs (watchers, assignees). + UserList, +} + +impl ColumnKind { + /// Short stable name used as the sub-template filename stem + /// (`dispatch/cell/.askama`). Append-only — never renamed. + pub fn template_stem(self) -> &'static str { + match self { + Self::Plain => "plain", + Self::IdLink => "id_link", + Self::PrimaryLink => "primary_link", + Self::RecordRef => "record_ref", + Self::RichText => "rich_text", + Self::ProgressBar => "progress_bar", + Self::RelationList => "relation_list", + Self::Hours => "hours", + Self::AttachmentList => "attachment_list", + Self::UserList => "user_list", + } + } +} + +/// Sort direction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SortOrder { + /// Ascending. + Asc, + /// Descending. + Desc, +} + +impl SortOrder { + /// "asc" / "desc" — matches URL query-string conventions. + pub fn as_str(self) -> &'static str { + match self { + Self::Asc => "asc", + Self::Desc => "desc", + } + } +} + +/// Resolve a sensible default [`ColumnKind`] from a field's name + curator +/// type name. Mirrors the structural defaults Redmine's `column_value` +/// `case` encodes; consumers can override per-column at binding-struct +/// build time. +/// +/// Conventions: +/// - `id` / `*_id` → [`ColumnKind::IdLink`]. +/// - canonical primary_label name (`name` / `subject` / `title`) → +/// [`ColumnKind::PrimaryLink`]. +/// - `done_ratio` / `*_ratio` / `*_pct` → [`ColumnKind::ProgressBar`]. +/// - `*_hours` → [`ColumnKind::Hours`]. +/// - Rails `text` type with prose-shaped name → [`ColumnKind::RichText`]. +/// - everything else → [`ColumnKind::Plain`]. +#[must_use] +pub fn default_kind_for(name: &str, type_name: Option<&str>) -> ColumnKind { + let n = name; + if n == "id" || n.ends_with("_id") { + return ColumnKind::IdLink; + } + if matches!(n, "name" | "subject" | "title" | "label") { + return ColumnKind::PrimaryLink; + } + if n == "done_ratio" || n.ends_with("_ratio") || n.ends_with("_pct") { + return ColumnKind::ProgressBar; + } + if n.ends_with("_hours") || n == "hours" { + return ColumnKind::Hours; + } + if matches!(type_name, Some("text")) + && (n.contains("description") + || n.contains("notes") + || n.contains("comment") + || n.contains("body")) + { + return ColumnKind::RichText; + } + ColumnKind::Plain +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_column_defaults_are_conservative() { + let c = RenderColumn::new("subject", "Subject", ColumnKind::Plain); + assert!(!c.sortable); + assert!(!c.groupable); + assert!(!c.totalable); + assert!(c.inline); + assert!(!c.frozen); + assert_eq!(c.default_order, SortOrder::Asc); + } + + #[test] + fn render_column_builder_composes() { + let c = RenderColumn::new("id", "#", ColumnKind::IdLink) + .sortable() + .frozen() + .default_order(SortOrder::Desc); + assert!(c.sortable && c.frozen); + assert_eq!(c.default_order, SortOrder::Desc); + } + + #[test] + fn column_kind_template_stems_are_stable() { + // The stem IS the filename — must never change once shipped. + assert_eq!(ColumnKind::Plain.template_stem(), "plain"); + assert_eq!(ColumnKind::IdLink.template_stem(), "id_link"); + assert_eq!(ColumnKind::PrimaryLink.template_stem(), "primary_link"); + assert_eq!(ColumnKind::ProgressBar.template_stem(), "progress_bar"); + assert_eq!(ColumnKind::Hours.template_stem(), "hours"); + assert_eq!(ColumnKind::RichText.template_stem(), "rich_text"); + } + + #[test] + fn default_kind_picks_link_for_id() { + assert_eq!(default_kind_for("id", None), ColumnKind::IdLink); + assert_eq!(default_kind_for("project_id", None), ColumnKind::IdLink); + } + + #[test] + fn default_kind_picks_primary_link_for_headline_names() { + for n in ["name", "subject", "title", "label"] { + assert_eq!( + default_kind_for(n, Some("string")), + ColumnKind::PrimaryLink, + "{n} should be primary link" + ); + } + } + + #[test] + fn default_kind_picks_progress_bar_for_ratios() { + assert_eq!(default_kind_for("done_ratio", None), ColumnKind::ProgressBar); + assert_eq!(default_kind_for("complete_pct", None), ColumnKind::ProgressBar); + } + + #[test] + fn default_kind_picks_hours_for_durations() { + assert_eq!(default_kind_for("estimated_hours", None), ColumnKind::Hours); + assert_eq!(default_kind_for("spent_hours", None), ColumnKind::Hours); + } + + #[test] + fn default_kind_picks_rich_text_only_for_text_typed_prose_names() { + assert_eq!(default_kind_for("description", Some("text")), ColumnKind::RichText); + assert_eq!(default_kind_for("last_notes", Some("text")), ColumnKind::RichText); + // Same name with a non-text type stays Plain — the type gates it. + assert_eq!(default_kind_for("description", Some("string")), ColumnKind::Plain); + // Text type with non-prose name stays Plain — the name gates it. + assert_eq!(default_kind_for("status_label", Some("text")), ColumnKind::Plain); + } + + #[test] + fn default_kind_falls_back_to_plain() { + assert_eq!(default_kind_for("position", Some("integer")), ColumnKind::Plain); + assert_eq!(default_kind_for("created_at", Some("datetime")), ColumnKind::Plain); + } +} diff --git a/crates/ogar-render-askama/src/spec.rs b/crates/ogar-render-askama/src/spec.rs index 9ab150b..4e93cf9 100644 --- a/crates/ogar-render-askama/src/spec.rs +++ b/crates/ogar-render-askama/src/spec.rs @@ -14,27 +14,27 @@ use ogar_vocab::Class; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ArtifactKind { /// Rust `struct` definition + `pub const CLASS_ID: u16` constant. - /// Codegen flavour — downstream compiler is the final consumer. + /// **Codegen flavour** — downstream compiler is the final consumer. RustStruct, - /// **Deprecated** — to be removed when T2 lands. Anti-pattern #8 in - /// the Northstar plan: askama is the rendering layer (server-side - /// HTML), not a producer of TypeScript source. The frontend in the - /// WoA-rs pattern reads askama-rendered HTML directly. PR #80 - /// (TsInterface emitter) was closed for this reason. Variant kept - /// for one merge cycle so the dispatcher's stub fallback still has - /// something to fall back to; T2 replaces this variant with - /// `HtmlListView`. - TsInterface, + /// Tabular HTML list view rendered server-side via `askama_axum` or + /// equivalent. **Render flavour** — the askama-rendered HTML *is* the + /// output the user reads. Inherits the Redmine `Query` + + /// `column_content` pattern (17 years of evolution) mapped onto our + /// `ClassView` substrate. Spec: `docs/integration/REDMINE-QUERY- + /// HARVEST.md` §3. + HtmlListView, /// SurrealQL `DEFINE TABLE` + per-field `DEFINE FIELD` statements. - /// Codegen flavour — DB engine is the final consumer. + /// **Codegen flavour** — DB engine is the final consumer. SurrealqlTable, - /// **Deprecated** — to be removed when T2 lands. See Anti-pattern #8. - /// OpenAPI schemas serve TS / external SDK consumers the canonical - /// pattern doesn't have; demand-driven, not in the +5 kit. + /// **Deprecated** — anti-pattern #8: askama is the render output, not + /// a producer of other-language schemas. OpenAPI consumers outside + /// the WoA pattern can implement their own ClassView-based generator. + /// Variant kept one cycle so existing dispatchers still match; + /// removed in the next kit cleanup. OpenapiSchema, /// Rust `match` arm dispatching on `ClassId` — useful for routing on - /// `NodeGuid::classid` in graph consumers. Codegen flavour. Moves to - /// the Northstar roadmap (post-+5+5); not in the bootstrap kit. + /// `NodeGuid::classid` in graph consumers. Codegen flavour. Roadmap + /// (post-+5+5); not in the bootstrap kit. NodeGuidRoutingArm, } @@ -43,7 +43,7 @@ impl ArtifactKind { /// `ArtifactKind` enum is treated append-only). pub const ALL: &'static [Self] = &[ Self::RustStruct, - Self::TsInterface, + Self::HtmlListView, Self::SurrealqlTable, Self::OpenapiSchema, Self::NodeGuidRoutingArm, @@ -54,7 +54,7 @@ impl ArtifactKind { pub fn name(self) -> &'static str { match self { Self::RustStruct => "rust_struct", - Self::TsInterface => "ts_interface", + Self::HtmlListView => "html_list_view", Self::SurrealqlTable => "surrealql_table", Self::OpenapiSchema => "openapi_schema", Self::NodeGuidRoutingArm => "node_guid_routing_arm", diff --git a/crates/ogar-render-askama/templates/dispatch/cell/attachment_list.askama b/crates/ogar-render-askama/templates/dispatch/cell/attachment_list.askama new file mode 100644 index 0000000..d789396 --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/cell/attachment_list.askama @@ -0,0 +1,6 @@ +{# Attachment-list cell — Redmine `:attachments`. #} + diff --git a/crates/ogar-render-askama/templates/dispatch/cell/hours.askama b/crates/ogar-render-askama/templates/dispatch/cell/hours.askama new file mode 100644 index 0000000..428aae5 --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/cell/hours.askama @@ -0,0 +1,6 @@ +{# Hours cell — Redmine `:estimated_hours` / `:spent_hours` arms. #} +{% if href.len() > 0 -%} +{{ hours }} +{%- else -%} +{{ hours }} +{%- endif %} diff --git a/crates/ogar-render-askama/templates/dispatch/cell/id_link.askama b/crates/ogar-render-askama/templates/dispatch/cell/id_link.askama new file mode 100644 index 0000000..7f4ad7f --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/cell/id_link.askama @@ -0,0 +1,2 @@ +{# Id cell — Redmine `:id` arm. #} +#{{ id }} diff --git a/crates/ogar-render-askama/templates/dispatch/cell/plain.askama b/crates/ogar-render-askama/templates/dispatch/cell/plain.askama new file mode 100644 index 0000000..3d3f77f --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/cell/plain.askama @@ -0,0 +1,2 @@ +{# Plain cell — fallback formatter, Redmine's `format_object`. #} +{{ value }} diff --git a/crates/ogar-render-askama/templates/dispatch/cell/primary_link.askama b/crates/ogar-render-askama/templates/dispatch/cell/primary_link.askama new file mode 100644 index 0000000..4b7e12a --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/cell/primary_link.askama @@ -0,0 +1,2 @@ +{# Primary link cell — Redmine `:subject` arm. Headline column for the row. #} +{{ label }} diff --git a/crates/ogar-render-askama/templates/dispatch/cell/progress_bar.askama b/crates/ogar-render-askama/templates/dispatch/cell/progress_bar.askama new file mode 100644 index 0000000..4669cbe --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/cell/progress_bar.askama @@ -0,0 +1,5 @@ +{# Progress-bar cell — Redmine `:done_ratio`. #} +
+
+ {{ pct }}% +
diff --git a/crates/ogar-render-askama/templates/dispatch/cell/record_ref.askama b/crates/ogar-render-askama/templates/dispatch/cell/record_ref.askama new file mode 100644 index 0000000..3352e1d --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/cell/record_ref.askama @@ -0,0 +1,6 @@ +{# Record-ref cell — Redmine `:parent` arm. Family-edge link to target. #} +{% if href.len() > 0 -%} +{{ label }} +{%- else -%} +{{ label }} +{%- endif %} diff --git a/crates/ogar-render-askama/templates/dispatch/cell/relation_list.askama b/crates/ogar-render-askama/templates/dispatch/cell/relation_list.askama new file mode 100644 index 0000000..14a7f78 --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/cell/relation_list.askama @@ -0,0 +1,6 @@ +{# Relation-list cell — Redmine `:relations`. #} +
    +{%- for r in relations %} +
  • #{{ r.id }}
  • +{%- endfor %} +
diff --git a/crates/ogar-render-askama/templates/dispatch/cell/rich_text.askama b/crates/ogar-render-askama/templates/dispatch/cell/rich_text.askama new file mode 100644 index 0000000..3391a20 --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/cell/rich_text.askama @@ -0,0 +1,4 @@ +{# Rich-text cell — Redmine `:description` / `:last_notes`. The body is #} +{# pre-rendered by a textile/markdown filter upstream; this template just #} +{# wraps it in a `.wiki` container. Block-column candidate. #} +
{{ body|safe }}
diff --git a/crates/ogar-render-askama/templates/dispatch/cell/user_list.askama b/crates/ogar-render-askama/templates/dispatch/cell/user_list.askama new file mode 100644 index 0000000..f65e735 --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/cell/user_list.askama @@ -0,0 +1,6 @@ +{# User-list cell — Redmine `:watcher_users` / assignees. #} + diff --git a/crates/ogar-render-askama/templates/dispatch/html_list_view.askama b/crates/ogar-render-askama/templates/dispatch/html_list_view.askama new file mode 100644 index 0000000..ce3238b --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/html_list_view.askama @@ -0,0 +1,56 @@ +{# Spine list template — Redmine `_list.html.erb` shape on our substrate. #} +{# Substrate-agnostic: nothing here knows it's rendering issues vs projects #} +{# vs users. Driven by `inline_columns`, `block_columns`, `rows`. #} +
+

{{ title }}

+
+ + + + + {%- for col in inline_columns %} + + {%- endfor %} + + + + + {%- for row in rows %} + {%- if !row.group_header_label.is_empty() %} + + + + {%- endif %} + + + {%- for cell in row.inline_cells %} + + {%- endfor %} + + + {%- for cell in row.block_cells %} + {%- if !cell.body_html.is_empty() %} + + + + {%- endif %} + {%- endfor %} + {%- endfor %} + {%- if rows.is_empty() %} + + {%- endif %} + +
+ {%- if col.sortable -%} + {{ col.caption }} + {%- else -%} + {{ col.caption }} + {%- endif -%} +
+ {{ row.group_header_label }} + {%- if row.group_header_count > 0 %} {{ row.group_header_count }}{% endif -%} +
{{ cell.body_html|safe }}
+ {{ cell.body_html|safe }} +
No data.
+
+