crates/cli/ents-web/src/render.rs
render.rshistorycomment on this file
| 1 | //! The generic, schema-driven list/view rendering mechanism -- the UI |
| 2 | //! analog of the gate executor named in this crate's development-plan |
| 3 | //! row: one reflection walk over any `#[derive(Facet)]` entity's |
| 4 | //! [`facet::Shape`], reused for every kernel entity this crate lists or |
| 5 | //! shows, rather than one hand-written renderer per entity type. The walk |
| 6 | //! itself is [`ents_forge::present`]'s: presentation policy (the `ents` |
| 7 | //! attributes -- skip, `skip_empty`, id-abbreviation, head/col column |
| 8 | //! selection) is declared once on the entity's own fields and obeyed here |
| 9 | //! exactly as the CLI obeys it; this module keeps only what is genuinely |
| 10 | //! web-shaped -- Markup wrapping, href columns, long-token cell breaking, |
| 11 | //! and the unreadable-entity degradation cards. |
| 12 | //! |
| 13 | //! The binding rule this module exists to uphold: nothing here ever |
| 14 | //! matches on *which* concrete type it was handed. [`fields`] walks |
| 15 | //! whatever [`facet::Shape`] the type reflects, by field name and |
| 16 | //! position, exactly the same way for [`ents_model::Member`], |
| 17 | //! [`ents_model::Effect`], [`ents_model::Redaction`], or |
| 18 | //! [`ents_model::Account`]. A page that genuinely needs to know it is |
| 19 | //! showing a comment (to render an anchor's projected diff) or a |
| 20 | //! toolchain (to render a recipe's provenance) is not a gap in this |
| 21 | //! module -- it is [`crate::pages::comments`] or [`crate::pages::toolchains`] |
| 22 | //! choosing a legitimate custom view instead of this generic one, exactly |
| 23 | //! as this crate's development-plan row anticipates. |
| 24 | |
| 25 | use facet::Facet; |
| 26 | use maud::{Markup, html}; |
| 27 | |
| 28 | /// One field's name and rendered value, in declaration order. |
| 29 | pub type FieldRow = (&'static str, String); |
| 30 | |
| 31 | /// Reflect over `value`'s [`facet::Shape`] and return one `(name, value)` |
| 32 | /// pair per field the entity's own `ents` attributes present in a view: |
| 33 | /// `ents::skip` fields are omitted, `ents::skip_empty` fields omitted when |
| 34 | /// empty, id-valued fields abbreviated, and the `ents::body` field ordered |
| 35 | /// last -- [`ents_forge::present::view`]'s policy, one walk shared with |
| 36 | /// the CLI's `show` ([`ents_forge::present::fields`]). |
| 37 | /// |
| 38 | /// A field's value renders via its own `Display` impl when it has one |
| 39 | /// (plain text, no `Type::Foo(...)` wrapper), falling back to `Debug` so |
| 40 | /// an enum without `Display` still shows its variant name rather than an |
| 41 | /// opaque placeholder. A non-struct `T` renders as an empty list, not a |
| 42 | /// panic -- reflection is a UI convenience, never a correctness path. |
| 43 | /// |
| 44 | /// # Examples |
| 45 | /// |
| 46 | /// ``` |
| 47 | /// use ents_model::{Member, Provenance}; |
| 48 | /// |
| 49 | /// let member = Member::new("jdc", "ssh-ed25519 AAAA... jdc", Provenance::AdminRegistered); |
| 50 | /// let rows = ents_web::render::fields(&member); |
| 51 | /// assert_eq!(rows[0].0, "id"); |
| 52 | /// assert_eq!(rows[1].0, "key"); |
| 53 | /// assert!(rows[1].1.contains("ssh-ed25519")); |
| 54 | /// assert_eq!(rows[2].0, "state"); |
| 55 | /// assert_eq!(rows[2].1, "active"); |
| 56 | /// ``` |
| 57 | // @relation(model.presentation, scope=function) |
| 58 | #[must_use] |
| 59 | pub fn fields<T: Facet<'static>>(value: &T) -> Vec<FieldRow> { |
| 60 | let walked = ents_forge::present::fields(value); |
| 61 | let (body, lines): (Vec<_>, Vec<_>) = walked |
| 62 | .into_iter() |
| 63 | .filter(|field| !(field.skip_empty && field.empty)) |
| 64 | .partition(|field| field.body); |
| 65 | lines |
| 66 | .into_iter() |
| 67 | .chain(body) |
| 68 | .map(|field| (field.name, field.value)) |
| 69 | .collect() |
| 70 | } |
| 71 | |
| 72 | /// The `(name, value)` list columns `value`'s own `ents` attributes |
| 73 | /// select: `ents::head` fields first, then `ents::col` fields, matching |
| 74 | /// [`ents_forge::present::columns`]'s order -- or, for an entity declaring |
| 75 | /// no column at all, every walked field, so an unannotated entity still |
| 76 | /// lists in full rather than as a bare id column. |
| 77 | fn list_columns<T: Facet<'static>>(value: &T) -> Vec<FieldRow> { |
| 78 | let walked = ents_forge::present::fields(value); |
| 79 | if walked.iter().any(|field| field.head || field.col) { |
| 80 | let heads = walked.iter().filter(|field| field.head); |
| 81 | let cols = walked.iter().filter(|field| field.col && !field.head); |
| 82 | heads |
| 83 | .chain(cols) |
| 84 | .map(|field| (field.name, field.value.clone())) |
| 85 | .collect() |
| 86 | } else { |
| 87 | walked |
| 88 | .into_iter() |
| 89 | .map(|field| (field.name, field.value)) |
| 90 | .collect() |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /// A definition-list view of one entity's fields -- the generic "show" |
| 95 | /// page every kernel entity this crate exposes uses. |
| 96 | /// |
| 97 | /// # Examples |
| 98 | /// |
| 99 | /// ``` |
| 100 | /// use ents_model::{Account, MemberId}; |
| 101 | /// |
| 102 | /// let account = Account { member: MemberId::new("jdc"), login: "jdc@ents.test".to_owned() }; |
| 103 | /// let markup = ents_web::render::view(&account); |
| 104 | /// assert!(markup.into_string().contains("login")); |
| 105 | /// ``` |
| 106 | #[must_use] |
| 107 | pub fn view<T: Facet<'static>>(value: &T) -> Markup { |
| 108 | let rows = fields(value); |
| 109 | html! { |
| 110 | div.card { |
| 111 | dl.entity-view { |
| 112 | @for (name, rendered) in &rows { |
| 113 | dt { (name) } |
| 114 | dd { (rendered) } |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | /// A table listing `rows`, one row per `(id, entity)` pair, columns taken |
| 122 | /// from the first entity's own reflected field names -- the generic |
| 123 | /// "list" page every kernel entity this crate exposes uses. Which columns |
| 124 | /// render is the entity's own `ents` declaration ([`list_columns`]): its |
| 125 | /// `ents::head` fields lead, its `ents::col` fields follow -- the same |
| 126 | /// selection and order the CLI's `list` derives -- with the full field |
| 127 | /// walk as the fallback for an entity declaring no column. |
| 128 | /// |
| 129 | /// `id_header` names the leading column holding each entry's key (a |
| 130 | /// username, an effect name, a redaction id -- whatever names the ref this |
| 131 | /// listing was read from, which is never itself a field on the entity). |
| 132 | /// |
| 133 | /// Rows are the readable entities only: a ref whose stored tree this |
| 134 | /// build's `#[derive(Facet)]` shape could not read back is not this |
| 135 | /// table's row to render -- the page surfaces it through |
| 136 | /// [`unreadable_disclosure`] beside this table instead (the one place |
| 137 | /// unreadable entities render, for every family alike), and its own show |
| 138 | /// page still renders [`unreadable`]'s marker card. |
| 139 | /// |
| 140 | /// # Examples |
| 141 | /// |
| 142 | /// ``` |
| 143 | /// use ents_model::{Member, Provenance}; |
| 144 | /// |
| 145 | /// let rows = vec![ |
| 146 | /// ("jdc".to_owned(), Member::new("jdc", "key-a", Provenance::AdminRegistered)), |
| 147 | /// ]; |
| 148 | /// let rendered = ents_web::render::list_table(&rows, "username", |id| format!("/members/{id}")).into_string(); |
| 149 | /// assert!(rendered.contains("jdc")); |
| 150 | /// assert!(rendered.contains("key-a")); |
| 151 | /// ``` |
| 152 | #[must_use] |
| 153 | pub fn list_table<T: Facet<'static>>( |
| 154 | rows: &[(String, T)], |
| 155 | id_header: &str, |
| 156 | href_for: impl Fn(&str) -> String, |
| 157 | ) -> Markup { |
| 158 | let field_names: Vec<&'static str> = rows |
| 159 | .first() |
| 160 | .map(|(_, entity)| { |
| 161 | list_columns(entity) |
| 162 | .into_iter() |
| 163 | .map(|(name, _)| name) |
| 164 | .collect() |
| 165 | }) |
| 166 | .unwrap_or_default(); |
| 167 | html! { |
| 168 | div.card { |
| 169 | table.entity-list { |
| 170 | thead { |
| 171 | tr { |
| 172 | th { (id_header) } |
| 173 | @for name in &field_names { |
| 174 | th { (name) } |
| 175 | } |
| 176 | } |
| 177 | } |
| 178 | tbody { |
| 179 | @for (id, entity) in rows { |
| 180 | tr { |
| 181 | td { a href=(href_for(id)) { (id) } } |
| 182 | @for (_, rendered) in list_columns(entity) { |
| 183 | td.long-token[has_long_token(&rendered)] { (rendered) } |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | } |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /// Whether `value` holds a token no wrap opportunity ever splits -- an ssh |
| 194 | /// key's base64 body, an unbroken hash -- long enough (over 40 characters) |
| 195 | /// that its cell must be allowed to break mid-token (`.long-token`'s |
| 196 | /// `break-all`) or it starves every other column of the table's width. |
| 197 | /// Ordinary short values keep word-boundary wrapping so a variant name |
| 198 | /// like `AdminRegistered` never shreds. |
| 199 | fn has_long_token(value: &str) -> bool { |
| 200 | value.split_whitespace().any(|token| token.len() > 40) |
| 201 | } |
| 202 | |
| 203 | /// A muted marker card for one entity this crate could not reflect -- the |
| 204 | /// `GET /{family}/{id}` show-page counterpart to [`list_table`]'s per-row |
| 205 | /// marker: the same "unreadable" note, plus `detail` (the underlying |
| 206 | /// deserialization error) rendered verbatim in muted monospace, so an |
| 207 | /// operator can diagnose the schema mismatch without leaving the browser. |
| 208 | /// Never a 500 -- reading an older or unrelated schema's tree degrades to |
| 209 | /// this card, exactly as [`list_table`] degrades one row of a listing. |
| 210 | /// |
| 211 | /// # Examples |
| 212 | /// |
| 213 | /// ``` |
| 214 | /// let rendered = ents_web::render::unreadable("object ... is not a blob").into_string(); |
| 215 | /// assert!(rendered.contains("unreadable")); |
| 216 | /// assert!(rendered.contains("is not a blob")); |
| 217 | /// ``` |
| 218 | #[must_use] |
| 219 | pub fn unreadable(detail: &str) -> Markup { |
| 220 | html! { |
| 221 | div.card { |
| 222 | div.card-row.unreadable { |
| 223 | span { "unreadable \u{2014} written by an older schema" } |
| 224 | } |
| 225 | div.card-row { |
| 226 | code.unreadable-detail { (detail) } |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | /// The subtle "this page has unreadable entities" disclosure a list page |
| 233 | /// renders when one or more refs under its prefix failed to read back as |
| 234 | /// this build's entity shape: a muted `<details>` badge ("N unreadable", |
| 235 | /// warning glyph) that expands -- no JS, just the element's own toggle -- |
| 236 | /// to a small card listing each failed refname and its error text. One |
| 237 | /// component for every entity family (members, effects, redactions, |
| 238 | /// toolchains, comments, issues), so unreadable entities are surfaced the |
| 239 | /// same way everywhere instead of a per-page mix of inline rows and |
| 240 | /// silent gaps. Renders nothing at all when `items` is empty, so a |
| 241 | /// healthy page carries no extra markup. |
| 242 | /// |
| 243 | /// # Examples |
| 244 | /// |
| 245 | /// ``` |
| 246 | /// let items = vec![( |
| 247 | /// "refs/meta/comments/legacy".to_owned(), |
| 248 | /// "object ... is not a blob".to_owned(), |
| 249 | /// )]; |
| 250 | /// let rendered = ents_web::render::unreadable_disclosure(&items).into_string(); |
| 251 | /// assert!(rendered.contains("<details")); |
| 252 | /// assert!(rendered.contains("1 unreadable")); |
| 253 | /// assert!(rendered.contains("refs/meta/comments/legacy")); |
| 254 | /// assert!(ents_web::render::unreadable_disclosure(&[]).into_string().is_empty()); |
| 255 | /// ``` |
| 256 | #[must_use] |
| 257 | pub fn unreadable_disclosure(items: &[(String, String)]) -> Markup { |
| 258 | if items.is_empty() { |
| 259 | return html! {}; |
| 260 | } |
| 261 | html! { |
| 262 | details.unreadable-note { |
| 263 | summary { |
| 264 | "\u{26a0} " (items.len()) " unreadable" |
| 265 | } |
| 266 | div.card { |
| 267 | dl.entity-view { |
| 268 | @for (refname, error) in items { |
| 269 | dt { (refname) } |
| 270 | dd { (error) } |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | } |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | /// A key-value properties table for a rendered document's own metadata -- |
| 279 | /// Markdown frontmatter ([`crate::markdown`]) and an AsciiDoc header's |
| 280 | /// attribute entries ([`crate::asciidoc`]) both render through this one |
| 281 | /// component, above the document body, styled by `ents.css`'s |
| 282 | /// `.doc-props` rules on top of the same `.entity-view` definition-list |
| 283 | /// look every generic entity view already has. Values are plain text |
| 284 | /// (maud-escaped as any interpolation is); a nested structure the caller |
| 285 | /// chose not to parse arrives here as its raw text and renders verbatim |
| 286 | /// (`.doc-props dd` preserves its line breaks). Renders nothing at all |
| 287 | /// when `entries` is empty, so a document with no metadata carries no |
| 288 | /// empty table. |
| 289 | /// |
| 290 | /// # Examples |
| 291 | /// |
| 292 | /// ``` |
| 293 | /// let entries = vec![("title".to_owned(), "Design Notes".to_owned())]; |
| 294 | /// let rendered = ents_web::render::properties_table(&entries).into_string(); |
| 295 | /// assert!(rendered.contains("doc-props")); |
| 296 | /// assert!(rendered.contains("Design Notes")); |
| 297 | /// assert!(ents_web::render::properties_table(&[]).into_string().is_empty()); |
| 298 | /// ``` |
| 299 | #[must_use] |
| 300 | pub fn properties_table(entries: &[(String, String)]) -> Markup { |
| 301 | if entries.is_empty() { |
| 302 | return html! {}; |
| 303 | } |
| 304 | html! { |
| 305 | dl.entity-view.doc-props { |
| 306 | @for (key, value) in entries { |
| 307 | dt { (key) } |
| 308 | dd { (value) } |
| 309 | } |
| 310 | } |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | /// A list of plain strings with no reflected entity behind them (inbox |
| 315 | /// entries, toolchain names) -- deliberately not the [`fields`] mechanism, |
| 316 | /// since there is no struct to reflect over, only a bare list of ids. |
| 317 | #[must_use] |
| 318 | pub fn string_list(rows: &[String], href_for: impl Fn(&str) -> String) -> Markup { |
| 319 | html! { |
| 320 | div.card { |
| 321 | ul.string-list { |
| 322 | @for row in rows { |
| 323 | li { a href=(href_for(row)) { (row) } } |
| 324 | } |
| 325 | } |
| 326 | } |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | #[cfg(test)] |
| 331 | mod tests { |
| 332 | #![allow(clippy::expect_used, reason = "unit test")] |
| 333 | |
| 334 | use ents_model::{Account, Effect, Member, MemberId, MemberState, Provenance, Redaction}; |
| 335 | use rstest::rstest; |
| 336 | |
| 337 | use super::*; |
| 338 | |
| 339 | #[rstest] |
| 340 | // @relation(roots.web-agnostic, scope=function, role=Verifies) |
| 341 | fn fields_walks_every_declared_field_in_order_for_any_kernel_entity() { |
| 342 | let member = Member::new( |
| 343 | "jdc", |
| 344 | "ssh-ed25519 AAAA... jdc", |
| 345 | Provenance::AdminRegistered, |
| 346 | ); |
| 347 | let rows = fields(&member); |
| 348 | assert_eq!( |
| 349 | rows.iter().map(|(name, _)| *name).collect::<Vec<_>>(), |
| 350 | vec!["id", "key", "state", "provenance"] |
| 351 | ); |
| 352 | } |
| 353 | |
| 354 | #[rstest] |
| 355 | // @relation(roots.web-agnostic, scope=function, role=Verifies) |
| 356 | fn an_enum_field_renders_its_variant_name_not_a_placeholder() { |
| 357 | let member = Member::new("jdc", "key", Provenance::AdminRegistered); |
| 358 | let rows = fields(&member); |
| 359 | let (_, state) = rows |
| 360 | .iter() |
| 361 | .find(|(name, _)| *name == "state") |
| 362 | .expect("state field"); |
| 363 | assert_eq!(state, "active"); |
| 364 | assert_eq!(member.state, MemberState::Active); |
| 365 | } |
| 366 | |
| 367 | #[rstest] |
| 368 | #[case::member(Member::new("jdc", "k", Provenance::AdminRegistered))] |
| 369 | // @relation(roots.web-agnostic, scope=function, role=Verifies) |
| 370 | fn the_same_generic_view_renders_every_entity_type(#[case] member: Member) { |
| 371 | // Same call, no type-specific branch -- this is the whole point of |
| 372 | // the generic mechanism this module exists to prove. Each call's |
| 373 | // markup is asserted non-empty and containing a field name real to |
| 374 | // that entity, so this is a render check, not a discarded call. |
| 375 | assert!(view(&member).into_string().contains("provenance")); |
| 376 | assert!( |
| 377 | view(&Effect { |
| 378 | name: "unit".to_owned(), |
| 379 | trigger: "rev(refs/heads/main)".to_owned(), |
| 380 | toolchains: vec![], |
| 381 | run: "true".to_owned(), |
| 382 | }) |
| 383 | .into_string() |
| 384 | .contains("trigger") |
| 385 | ); |
| 386 | assert!( |
| 387 | view(&Redaction::new( |
| 388 | gix_hash::ObjectId::null(gix_hash::Kind::Sha1), |
| 389 | "why" |
| 390 | )) |
| 391 | .into_string() |
| 392 | .contains("reason") |
| 393 | ); |
| 394 | assert!( |
| 395 | view(&Account { |
| 396 | member: MemberId::new("jdc"), |
| 397 | login: "jdc@ents.test".to_owned(), |
| 398 | }) |
| 399 | .into_string() |
| 400 | .contains("login") |
| 401 | ); |
| 402 | } |
| 403 | |
| 404 | /// The `ents` attributes declared on the entity's own fields govern |
| 405 | /// the web exactly as they govern the CLI: `ents::skip` hides the |
| 406 | /// refname-bound name, `ents::skip_empty` hides an empty set, and the |
| 407 | /// list narrows to the declared `ents::col` columns. |
| 408 | #[rstest] |
| 409 | // @relation(model.presentation, roots.web-agnostic, scope=function, role=Verifies) |
| 410 | fn ents_attributes_govern_the_view_and_the_list_columns() { |
| 411 | let effect = Effect { |
| 412 | name: "unit".to_owned(), |
| 413 | trigger: "rev(refs/heads/main)".to_owned(), |
| 414 | toolchains: vec![], |
| 415 | run: "true".to_owned(), |
| 416 | }; |
| 417 | let names: Vec<_> = fields(&effect).into_iter().map(|(name, _)| name).collect(); |
| 418 | assert_eq!(names, vec!["trigger", "run"]); |
| 419 | |
| 420 | let rows = vec![("unit".to_owned(), effect)]; |
| 421 | let markup = list_table(&rows, "name", |id| format!("/effects/{id}")).into_string(); |
| 422 | assert!(markup.contains("trigger") && markup.contains("rev(refs/heads/main)")); |
| 423 | assert!( |
| 424 | !markup.contains("<th>run</th>"), |
| 425 | "only the declared columns render: {markup}" |
| 426 | ); |
| 427 | } |
| 428 | |
| 429 | #[rstest] |
| 430 | // @relation(roots.web-agnostic, scope=function, role=Verifies) |
| 431 | fn list_table_derives_its_columns_from_the_first_rows_own_shape() { |
| 432 | let rows = vec![( |
| 433 | "jdc".to_owned(), |
| 434 | Member::new("jdc", "key", Provenance::AdminRegistered), |
| 435 | )]; |
| 436 | let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string(); |
| 437 | assert!(markup.contains("username")); |
| 438 | assert!(markup.contains("key")); |
| 439 | assert!(markup.contains("jdc")); |
| 440 | } |
| 441 | |
| 442 | #[rstest] |
| 443 | // @relation(roots.web-agnostic, scope=function, role=Verifies) |
| 444 | fn list_table_breaks_only_the_cell_holding_a_long_unbroken_token() { |
| 445 | let key = format!("ssh-ed25519 {} jdc@host", "A".repeat(68)); |
| 446 | let rows = vec![( |
| 447 | "jdc".to_owned(), |
| 448 | Member::new("jdc", key, Provenance::AdminRegistered), |
| 449 | )]; |
| 450 | let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string(); |
| 451 | // Exactly one cell carries the class: the key's; `AdminRegistered` |
| 452 | // and the short id stay word-boundary-wrapped. |
| 453 | assert_eq!(markup.matches("class=\"long-token\"").count(), 1); |
| 454 | } |
| 455 | |
| 456 | #[rstest] |
| 457 | // @relation(roots.web-agnostic, scope=function, role=Verifies) |
| 458 | fn unreadable_disclosure_lists_each_failed_ref_behind_a_details_toggle() { |
| 459 | let items = vec![ |
| 460 | ( |
| 461 | "refs/meta/member/legacy".to_owned(), |
| 462 | "object ... is not a blob".to_owned(), |
| 463 | ), |
| 464 | ( |
| 465 | "refs/meta/member/older".to_owned(), |
| 466 | "missing field".to_owned(), |
| 467 | ), |
| 468 | ]; |
| 469 | let markup = unreadable_disclosure(&items).into_string(); |
| 470 | assert!(markup.contains("<details")); |
| 471 | assert!(markup.contains("2 unreadable")); |
| 472 | assert!(markup.contains("refs/meta/member/legacy")); |
| 473 | assert!(markup.contains("missing field")); |
| 474 | assert!( |
| 475 | unreadable_disclosure(&[]).into_string().is_empty(), |
| 476 | "a healthy page carries no disclosure at all" |
| 477 | ); |
| 478 | } |
| 479 | |
| 480 | #[rstest] |
| 481 | // @relation(roots.web-agnostic, scope=function, role=Verifies) |
| 482 | fn unreadable_card_shows_the_underlying_error() { |
| 483 | let markup = unreadable("object deadbeef is not a blob").into_string(); |
| 484 | assert!(markup.contains("unreadable")); |
| 485 | assert!(markup.contains("object deadbeef is not a blob")); |
| 486 | } |
| 487 | } |