|
| 1 | +//! `field_view` — the **addressed desktop surface** (charter C1.2 of |
| 2 | +//! `AdaWorldAPI/OGAR docs/A2UI-SCREEN-ADDRESSING-PROPOSAL.md`). |
| 3 | +//! |
| 4 | +//! This is the render half of *"don't push pixels — address the screen."* |
| 5 | +//! Where [`rust_class`](crate::rust_class) emits a masked class as Rust |
| 6 | +//! **source** (build-time codegen) and [`html_detail_view`](crate::artifact_kinds::html_detail_view) |
| 7 | +//! emits a record's detail page from `RenderColumn`/`CellSource`, this module |
| 8 | +//! renders a **`WideFieldMask`-projected [`ClassView`] instance** as a live, |
| 9 | +//! addressed screen — the operator's *"ERB pattern fieldview ClassView |
| 10 | +//! rendering from memory without serialization."* |
| 11 | +//! |
| 12 | +//! # What "addressed" means (the fieldview contract) |
| 13 | +//! |
| 14 | +//! - **Every field carries its POSITION** (`data-field-pos`) — the field's |
| 15 | +//! [`WideFieldMask`](lance_graph_contract::class_view::WideFieldMask) bit |
| 16 | +//! index, which IS its layout address (the ordered-set position the nested |
| 17 | +//! ClassView projects; the same `X:Y` rail documents and Klickwege surfaces |
| 18 | +//! use). The client repaints exactly the positions a `NodeDelta` mask |
| 19 | +//! carries — nothing else redraws. |
| 20 | +//! - **Every action carries its ORDINAL only** (`data-action-ordinal`, |
| 21 | +//! charter trap T2) — the index into the class's `ActionDef` set. A click |
| 22 | +//! emits `ActionInvoke { key, action_ordinal, args }`; the surface carries |
| 23 | +//! the *address* of behavior, never behavior. `onClick: <lambda>` in a |
| 24 | +//! component tree is the `DEFINE EVENT` hijack, and it is rejected by |
| 25 | +//! construction here — there is nowhere to put a handler. |
| 26 | +//! - **The key is the node address** (`data-key`) — the canonical 16-byte |
| 27 | +//! GUID as hex. The client prerenders layout from the key alone (OGAR P0 |
| 28 | +//! "the key prerenders nodes"); the fields fill in the addressed slots. |
| 29 | +//! |
| 30 | +//! # Where the values come from (ClassView, not the wire) |
| 31 | +//! |
| 32 | +//! [`FieldView`]s are built from a [`ClassView`] projection — see |
| 33 | +//! [`from_value_rows`], which maps |
| 34 | +//! [`ClassView::facet_rows`](lance_graph_contract::class_view::ClassView::facet_rows) |
| 35 | +//! output straight onto the surface. The labels are the meta-DTO's *late* |
| 36 | +//! resolution (above the SoA); the value bytes are the row's V3 facet. The |
| 37 | +//! render borrows both — nothing serializes in order to draw (charter T3). |
| 38 | +//! |
| 39 | +//! # XSS |
| 40 | +//! |
| 41 | +//! The template compiles with `escape = "html"`, and **every** interpolated |
| 42 | +//! field (title, labels, values, action captions) is data-derived, so all of |
| 43 | +//! it is HTML-escaped. There is no `|safe` escape hatch on this surface — a |
| 44 | +//! fieldview never carries pre-rendered HTML — which closes the class of |
| 45 | +//! injection the list/detail kits had to fix under codex P1 on #83/#84. |
| 46 | +
|
| 47 | +use askama::Template; |
| 48 | + |
| 49 | +use lance_graph_contract::class_view::{RenderRow, ValueRow}; |
| 50 | + |
| 51 | +/// One projected field on the addressed surface. |
| 52 | +/// |
| 53 | +/// `position` is the field's [`WideFieldMask`](lance_graph_contract::class_view::WideFieldMask) |
| 54 | +/// bit index — its **layout address** on the screen. `value` is the |
| 55 | +/// already-formatted display text (the consumer's ClassView formats the raw |
| 56 | +/// facet byte / value-slab field into text); the template escapes it. |
| 57 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 58 | +pub struct FieldView { |
| 59 | + /// The field's mask position — its layout address (the ordered-set slot). |
| 60 | + pub position: u8, |
| 61 | + /// The display label, late-resolved from the ontology cache (above the SoA). |
| 62 | + pub label: String, |
| 63 | + /// The field's predicate IRI (the stable key behind the label). |
| 64 | + pub predicate: String, |
| 65 | + /// The formatted value text — escaped by the template. |
| 66 | + pub value: String, |
| 67 | +} |
| 68 | + |
| 69 | +/// One action the surface exposes — carried by **address only** (charter T2). |
| 70 | +/// |
| 71 | +/// `ordinal` is the index into the class's `ActionDef` set; a click emits |
| 72 | +/// `ActionInvoke { key, action_ordinal: ordinal, args }`. There is no handler |
| 73 | +/// field — behavior lives on the Core node, never on the surface. |
| 74 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 75 | +pub struct ActionRef { |
| 76 | + /// Index into the class's `ActionDef` set — the invocation address. |
| 77 | + pub ordinal: u32, |
| 78 | + /// The human caption for the control (escaped by the template). |
| 79 | + pub label: String, |
| 80 | +} |
| 81 | + |
| 82 | +// ── askama binding struct ──────────────────────────────────────────── |
| 83 | + |
| 84 | +#[derive(Template)] |
| 85 | +#[template(path = "dispatch/field_view.askama", escape = "html")] |
| 86 | +struct FieldViewCtx<'a> { |
| 87 | + class_id_hex: String, |
| 88 | + canonical_concept: &'a str, |
| 89 | + key_hex: &'a str, |
| 90 | + title: &'a str, |
| 91 | + fields: &'a [FieldView], |
| 92 | + actions: &'a [ActionRef], |
| 93 | +} |
| 94 | + |
| 95 | +/// Render a `WideFieldMask`-projected [`ClassView`](lance_graph_contract::class_view::ClassView) |
| 96 | +/// instance as an **addressed desktop surface** (the fieldview). |
| 97 | +/// |
| 98 | +/// - `class_id` / `canonical_concept` identify the class (the client resolves |
| 99 | +/// the template codebook from `class_id` — the font of the desktop). |
| 100 | +/// - `key_hex` is the node's canonical 16-byte GUID as hex — the node address. |
| 101 | +/// - `fields` are the projected, present fields (build them with |
| 102 | +/// [`from_value_rows`] / [`from_render_rows`] from a ClassView projection). |
| 103 | +/// - `actions` are the `ActionDef` addresses the surface exposes (T2). |
| 104 | +/// |
| 105 | +/// # Errors |
| 106 | +/// |
| 107 | +/// Propagates [`askama::Error`] if template rendering fails (it never should |
| 108 | +/// for well-formed input — the template has no fallible expressions). |
| 109 | +pub fn render_field_view( |
| 110 | + class_id: u16, |
| 111 | + canonical_concept: &str, |
| 112 | + key_hex: &str, |
| 113 | + title: &str, |
| 114 | + fields: &[FieldView], |
| 115 | + actions: &[ActionRef], |
| 116 | +) -> Result<String, askama::Error> { |
| 117 | + FieldViewCtx { |
| 118 | + class_id_hex: format!("0x{class_id:04X}"), |
| 119 | + canonical_concept, |
| 120 | + key_hex, |
| 121 | + title, |
| 122 | + fields, |
| 123 | + actions, |
| 124 | + } |
| 125 | + .render() |
| 126 | +} |
| 127 | + |
| 128 | +/// Build the surface's [`FieldView`]s from a ClassView's |
| 129 | +/// [`facet_rows`](lance_graph_contract::class_view::ClassView::facet_rows) |
| 130 | +/// output — the value-projected rows (each present facet-backed field paired |
| 131 | +/// with its byte). |
| 132 | +/// |
| 133 | +/// The raw facet byte is formatted with `fmt` (the consumer supplies the |
| 134 | +/// class's display formatting — e.g. an enum label, a scaled number); the |
| 135 | +/// default caller can pass `|b| b.to_string()`. This is the direct |
| 136 | +/// ClassView-facet → addressed-surface bridge: the mask already gated which |
| 137 | +/// rows exist (C2 presence, above the SoA), and each row already knows its |
| 138 | +/// `position` (layout address) — nothing about presence or address is |
| 139 | +/// recomputed here. |
| 140 | +pub fn from_value_rows(rows: &[ValueRow<'_>], mut fmt: impl FnMut(u8) -> String) -> Vec<FieldView> { |
| 141 | + rows.iter() |
| 142 | + .map(|r| FieldView { |
| 143 | + position: r.position, |
| 144 | + label: r.label.to_string(), |
| 145 | + predicate: r.predicate.to_string(), |
| 146 | + value: fmt(r.value), |
| 147 | + }) |
| 148 | + .collect() |
| 149 | +} |
| 150 | + |
| 151 | +/// Build the surface's [`FieldView`]s from a ClassView's |
| 152 | +/// [`render_rows`](lance_graph_contract::class_view::ClassView::render_rows) |
| 153 | +/// output — the label-only rows (no facet byte), paired positionally with the |
| 154 | +/// consumer's already-formatted value strings. |
| 155 | +/// |
| 156 | +/// `render_rows` yields `(label, predicate)` for each present field but not |
| 157 | +/// the field's mask position (it filters the projection down to present rows), |
| 158 | +/// so the caller supplies `positions` (the present bit indices, ascending) and |
| 159 | +/// `values` (one formatted value per present row) alongside. All three slices |
| 160 | +/// are index-aligned; the shortest wins (a length mismatch truncates rather |
| 161 | +/// than panics — the caller is expected to pass matched slices). |
| 162 | +pub fn from_render_rows( |
| 163 | + rows: &[RenderRow<'_>], |
| 164 | + positions: &[u8], |
| 165 | + values: &[String], |
| 166 | +) -> Vec<FieldView> { |
| 167 | + rows.iter() |
| 168 | + .zip(positions.iter()) |
| 169 | + .zip(values.iter()) |
| 170 | + .map(|((r, &position), value)| FieldView { |
| 171 | + position, |
| 172 | + label: r.label.to_string(), |
| 173 | + predicate: r.predicate.to_string(), |
| 174 | + value: value.clone(), |
| 175 | + }) |
| 176 | + .collect() |
| 177 | +} |
| 178 | + |
| 179 | +#[cfg(test)] |
| 180 | +mod tests { |
| 181 | + use super::*; |
| 182 | + |
| 183 | + fn sample_fields() -> Vec<FieldView> { |
| 184 | + vec![ |
| 185 | + FieldView { |
| 186 | + position: 0, |
| 187 | + label: "Total".to_string(), |
| 188 | + predicate: "amount_total".to_string(), |
| 189 | + value: "1200".to_string(), |
| 190 | + }, |
| 191 | + FieldView { |
| 192 | + // A position past 63 — the wide-surface case (charter C1.4): |
| 193 | + // the fieldview addresses fields beyond the 64-field ceiling. |
| 194 | + position: 133, |
| 195 | + label: "Partner".to_string(), |
| 196 | + predicate: "partner_id".to_string(), |
| 197 | + value: "ACME GmbH".to_string(), |
| 198 | + }, |
| 199 | + ] |
| 200 | + } |
| 201 | + |
| 202 | + #[test] |
| 203 | + fn renders_addressed_fields_and_action_ordinals() { |
| 204 | + let actions = vec![ActionRef { |
| 205 | + ordinal: 7, |
| 206 | + label: "Post".to_string(), |
| 207 | + }]; |
| 208 | + let src = render_field_view( |
| 209 | + 0x0102, |
| 210 | + "commercial_document", |
| 211 | + "0801000301020304", |
| 212 | + "Invoice #42", |
| 213 | + &sample_fields(), |
| 214 | + &actions, |
| 215 | + ) |
| 216 | + .unwrap(); |
| 217 | + |
| 218 | + // The surface is addressed by class + concept + key. |
| 219 | + assert!(src.contains("data-class-id=\"0x0102\""), "{src}"); |
| 220 | + assert!(src.contains("data-concept=\"commercial_document\""), "{src}"); |
| 221 | + assert!(src.contains("data-key=\"0801000301020304\""), "{src}"); |
| 222 | + // Each field carries its POSITION (layout address) — including the |
| 223 | + // wide position past 63. |
| 224 | + assert!(src.contains("data-field-pos=\"0\""), "{src}"); |
| 225 | + assert!( |
| 226 | + src.contains("data-field-pos=\"133\""), |
| 227 | + "wide position must address past the 64 ceiling:\n{src}" |
| 228 | + ); |
| 229 | + assert!(src.contains("Total"), "{src}"); |
| 230 | + assert!(src.contains("ACME GmbH"), "{src}"); |
| 231 | + // The action carries its ORDINAL address (T2) and the node key — never |
| 232 | + // an inline handler. |
| 233 | + assert!( |
| 234 | + src.contains("data-action-ordinal=\"7\""), |
| 235 | + "action must carry its ordinal address:\n{src}" |
| 236 | + ); |
| 237 | + assert!(!src.contains("onclick"), "no inline handler (T2):\n{src}"); |
| 238 | + } |
| 239 | + |
| 240 | + #[test] |
| 241 | + fn escapes_data_derived_strings_xss_regression() { |
| 242 | + // Every fieldview string is data-derived and MUST be escaped — the |
| 243 | + // fieldview has no `|safe` hatch (unlike the list/detail kits, which |
| 244 | + // needed codex-P1 fixes on #83/#84). A poisoned label/value/title/ |
| 245 | + // action-caption must not inject raw HTML. |
| 246 | + let fields = vec![FieldView { |
| 247 | + position: 0, |
| 248 | + label: "<script>alert('label')</script>".to_string(), |
| 249 | + predicate: "x".to_string(), |
| 250 | + value: "<img src=x onerror=alert('value')>".to_string(), |
| 251 | + }]; |
| 252 | + let actions = vec![ActionRef { |
| 253 | + ordinal: 0, |
| 254 | + label: "<script>alert('action')</script>".to_string(), |
| 255 | + }]; |
| 256 | + let src = render_field_view( |
| 257 | + 0x0102, |
| 258 | + "<concept-xss>", |
| 259 | + "<key-xss>", |
| 260 | + "<title-xss>x</title-xss>", |
| 261 | + &fields, |
| 262 | + &actions, |
| 263 | + ) |
| 264 | + .unwrap(); |
| 265 | + |
| 266 | + assert!(!src.contains("<script>alert('label')"), "label raw:\n{src}"); |
| 267 | + assert!(!src.contains("<img src=x onerror"), "value raw:\n{src}"); |
| 268 | + assert!(!src.contains("<script>alert('action')"), "action raw:\n{src}"); |
| 269 | + assert!(!src.contains("<title-xss>"), "title raw:\n{src}"); |
| 270 | + assert!(!src.contains("<concept-xss>"), "concept raw:\n{src}"); |
| 271 | + // The escaped form is present (quote encoding varies by askama |
| 272 | + // version, so assert only the angle-bracket escaping). |
| 273 | + assert!(src.contains("<script>"), "{src}"); |
| 274 | + } |
| 275 | + |
| 276 | + #[test] |
| 277 | + fn empty_actions_omit_the_nav() { |
| 278 | + let src = |
| 279 | + render_field_view(1, "c", "00", "T", &sample_fields(), &[]).unwrap(); |
| 280 | + assert!( |
| 281 | + !src.contains("fieldview-actions"), |
| 282 | + "no action nav when there are no actions:\n{src}" |
| 283 | + ); |
| 284 | + } |
| 285 | + |
| 286 | + #[test] |
| 287 | + fn from_value_rows_maps_classview_facet_projection() { |
| 288 | + // ValueRow is what ClassView::facet_rows yields — position + label + |
| 289 | + // predicate + the raw facet byte. from_value_rows maps it straight |
| 290 | + // onto the addressed surface, formatting the byte. |
| 291 | + let rows = vec![ |
| 292 | + ValueRow { |
| 293 | + label: "Status", |
| 294 | + predicate: "state", |
| 295 | + position: 3, |
| 296 | + value: 2, |
| 297 | + }, |
| 298 | + ValueRow { |
| 299 | + label: "Priority", |
| 300 | + predicate: "priority", |
| 301 | + position: 5, |
| 302 | + value: 9, |
| 303 | + }, |
| 304 | + ]; |
| 305 | + let fields = from_value_rows(&rows, |b| format!("code:{b}")); |
| 306 | + assert_eq!(fields.len(), 2); |
| 307 | + assert_eq!(fields[0].position, 3); |
| 308 | + assert_eq!(fields[0].label, "Status"); |
| 309 | + assert_eq!(fields[0].value, "code:2"); |
| 310 | + assert_eq!(fields[1].position, 5); |
| 311 | + assert_eq!(fields[1].value, "code:9"); |
| 312 | + |
| 313 | + // And it renders through the surface with those addresses. |
| 314 | + let src = render_field_view(0x0102, "c", "00", "T", &fields, &[]).unwrap(); |
| 315 | + assert!(src.contains("data-field-pos=\"3\""), "{src}"); |
| 316 | + assert!(src.contains("data-field-pos=\"5\""), "{src}"); |
| 317 | + assert!(src.contains("code:2"), "{src}"); |
| 318 | + } |
| 319 | + |
| 320 | + #[test] |
| 321 | + fn from_render_rows_pairs_positions_and_values() { |
| 322 | + let rows = vec![ |
| 323 | + RenderRow { |
| 324 | + label: "Total", |
| 325 | + predicate: "amount_total", |
| 326 | + }, |
| 327 | + RenderRow { |
| 328 | + label: "Partner", |
| 329 | + predicate: "partner_id", |
| 330 | + }, |
| 331 | + ]; |
| 332 | + let positions = [0u8, 133u8]; |
| 333 | + let values = ["1200".to_string(), "ACME".to_string()]; |
| 334 | + let fields = from_render_rows(&rows, &positions, &values); |
| 335 | + assert_eq!(fields.len(), 2); |
| 336 | + assert_eq!(fields[1].position, 133); |
| 337 | + assert_eq!(fields[1].label, "Partner"); |
| 338 | + assert_eq!(fields[1].value, "ACME"); |
| 339 | + } |
| 340 | +} |
0 commit comments